From 778fb07464b4e358c39bdb5ee3b5e31a9848ac82 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Tue, 29 Nov 2022 13:45:03 +0100 Subject: [PATCH 001/168] Plugins: Make the Plugin Details page reusable (#58741) * refactor(PluginDetails): use react-router hooks instead of props * Wip * refactor: remove unnecessary constant * feat: use the original plugin details page under connections * chore: use better wording in the not-found warning Co-authored-by: Jack Westbrook * chore: use the renderer utility everywhere in the test * chore: don't show a title while loading a plugin Co-authored-by: Jack Westbrook --- .betterer.results | 6 +- public/app/features/connections/constants.ts | 3 - .../pages/DataSourceDetailsPage.tsx | 35 ++++-- .../admin/components/PluginDetailsPage.tsx | 112 ++++++++++++++++++ .../admin/pages/PluginDetails.test.tsx | 36 +----- .../plugins/admin/pages/PluginDetails.tsx | 84 +------------ 6 files changed, 150 insertions(+), 126 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginDetailsPage.tsx diff --git a/.betterer.results b/.betterer.results index cb77689214a..b903dc1d5e6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4298,6 +4298,9 @@ exports[`better eslint`] = { "public/app/features/plugins/admin/components/PluginDetailsBody.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/plugins/admin/components/PluginDetailsPage.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/admin/components/SearchField.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -4316,9 +4319,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/plugins/admin/pages/PluginDetails.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/plugins/admin/state/actions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], diff --git a/public/app/features/connections/constants.ts b/public/app/features/connections/constants.ts index 561e44eed99..29d9c00ab3b 100644 --- a/public/app/features/connections/constants.ts +++ b/public/app/features/connections/constants.ts @@ -1,6 +1,3 @@ -// The ID of the app plugin that we render under that "Cloud Integrations" tab -export const CLOUD_ONBOARDING_APP_ID = 'grafana-easystart-app'; - // The ID of the main nav-tree item (the main item in the NavIndex) export const ROUTE_BASE_ID = 'connections'; diff --git a/public/app/features/connections/pages/DataSourceDetailsPage.tsx b/public/app/features/connections/pages/DataSourceDetailsPage.tsx index e39e0cafb8e..09e7a1795b5 100644 --- a/public/app/features/connections/pages/DataSourceDetailsPage.tsx +++ b/public/app/features/connections/pages/DataSourceDetailsPage.tsx @@ -1,24 +1,41 @@ import * as React from 'react'; +import { useParams } from 'react-router-dom'; -import { Page } from 'app/core/components/Page/Page'; -import { StoreState, useSelector } from 'app/types'; +import { Alert, Badge } from '@grafana/ui'; +import { PluginDetailsPage } from 'app/features/plugins/admin/components/PluginDetailsPage'; +import { StoreState, useSelector, AppNotificationSeverity } from 'app/types'; + +import { ROUTES } from '../constants'; export function DataSourceDetailsPage() { const overrideNavId = 'standalone-plugin-page-/connections/connect-data'; + const { id } = useParams<{ id: string }>(); const navIndex = useSelector((state: StoreState) => state.navIndex); const isConnectDataPageOverriden = Boolean(navIndex[overrideNavId]); const navId = isConnectDataPageOverriden ? overrideNavId : 'connections-connect-data'; // The nav id changes (gets a prefix) if it is overriden by a plugin return ( - } + notFoundNavModel={{ + text: 'Unknown datasource', + subTitle: 'No datasource with this ID could be found.', active: true, }} - > - Data Source Details (no exposed component from plugins yet) - + /> + ); +} + +function NotFoundDatasource() { + const { id } = useParams<{ id: string }>(); + + return ( + + Maybe you mistyped the URL or the plugin with the id is unavailable. +
+ To see a list of available datasources please click here. +
); } diff --git a/public/app/features/plugins/admin/components/PluginDetailsPage.tsx b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx new file mode 100644 index 00000000000..ad7b6ab7160 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsPage.tsx @@ -0,0 +1,112 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { useStyles2, TabContent, Alert } from '@grafana/ui'; +import { Layout } from '@grafana/ui/src/components/Layout/Layout'; +import { Page } from 'app/core/components/Page/Page'; +import { AppNotificationSeverity } from 'app/types'; + +import { Loader } from '../components/Loader'; +import { PluginDetailsBody } from '../components/PluginDetailsBody'; +import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError'; +import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; +import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; +import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; +import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; +import { PluginTabIds } from '../types'; + +export type Props = { + // The ID of the plugin + pluginId: string; + // The navigation ID used for displaying the sidebar navigation + navId?: string; + // Can be used to customise the title & subtitle for the not found page + notFoundNavModel?: NavModelItem; + // Can be used to customise the content shown when a plugin with the given ID cannot be found + notFoundComponent?: React.ReactElement; +}; + +export function PluginDetailsPage({ + pluginId, + navId = 'plugins', + notFoundComponent = , + notFoundNavModel = { + text: 'Unknown plugin', + subTitle: 'The requested ID does not belong to any plugin', + active: true, + }, +}: Props) { + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const plugin = useGetSingle(pluginId); // fetches the plugin settings for this Grafana instance + const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.get('page') as PluginTabIds); + const { actions, info, subtitle } = usePluginPageExtensions(plugin); + const { isLoading: isFetchLoading } = useFetchStatus(); + const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); + const styles = useStyles2(getStyles); + + if (isFetchLoading || isFetchDetailsLoading) { + return ( + + + + ); + } + + if (!plugin) { + return ( + + {notFoundComponent} + + ); + } + + return ( + + + + + + + + + + ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + alert: css` + margin-bottom: ${theme.spacing(2)}; + `, + subtitle: css` + display: flex; + flex-direction: column; + gap: ${theme.spacing(1)}; + `, + // Needed due to block formatting context + tabContent: css` + overflow: auto; + height: 100%; + `, + }; +}; + +function NotFoundPlugin() { + return ( + + + That plugin cannot be found. Please check the url is correct or
+ go to the plugin catalog. +
+
+ ); +} diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index f1b21bb99ac..16892cb07b4 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -2,7 +2,7 @@ import { getDefaultNormalizer, render, RenderResult, SelectorMatcherOptions, wai import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route } from 'react-router-dom'; import { PluginErrorCode, @@ -13,7 +13,6 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { configureStore } from 'app/store/configureStore'; import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock, mockUserPermissions } from '../__mocks__'; @@ -70,24 +69,14 @@ const renderPluginDetails = ( ): RenderResult => { const plugin = getCatalogPluginMock(pluginOverride); const { id } = plugin; - const props = getRouteComponentProps({ - match: { params: { pluginId: id }, isExact: true, url: '', path: '' }, - queryParams: { page: pageId }, - location: { - hash: '', - pathname: `/plugins/${id}`, - search: pageId ? `?page=${pageId}` : '', - state: undefined, - }, - }); const store = configureStore({ plugins: pluginsStateOverride || getPluginsStateMock([plugin]), }); return render( - + - + ); @@ -137,24 +126,7 @@ describe('Plugin details page', () => { local: { id }, }); - const props = getRouteComponentProps({ - match: { params: { pluginId: id }, isExact: true, url: '', path: '' }, - queryParams: {}, - location: { - hash: '', - pathname: `/plugins/${id}`, - search: '', - state: undefined, - }, - }); - const store = configureStore(); - const { queryByText } = render( - - - - - - ); + const { queryByText } = renderPluginDetails({ id }); await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument()); }); diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index 7b30a496878..575aac998f4 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,84 +1,10 @@ -import { css } from '@emotion/css'; import React from 'react'; +import { useParams } from 'react-router-dom'; -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, TabContent, Alert } from '@grafana/ui'; -import { Layout } from '@grafana/ui/src/components/Layout/Layout'; -import { Page } from 'app/core/components/Page/Page'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { AppNotificationSeverity } from 'app/types'; +import { PluginDetailsPage } from '../components/PluginDetailsPage'; -import { Loader } from '../components/Loader'; -import { PluginDetailsBody } from '../components/PluginDetailsBody'; -import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError'; -import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; -import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; -import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions'; -import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks'; -import { PluginTabIds } from '../types'; +export default function PluginDetails(): JSX.Element { + const { pluginId } = useParams<{ pluginId: string }>(); -type Props = GrafanaRouteComponentProps<{ pluginId?: string }>; - -export default function PluginDetails({ match, queryParams }: Props): JSX.Element | null { - const { - params: { pluginId = '' }, - url, - } = match; - const parentUrl = url.substring(0, url.lastIndexOf('/')); - - const plugin = useGetSingle(pluginId); // fetches the localplugin settings - const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.page as PluginTabIds); - const { actions, info, subtitle } = usePluginPageExtensions(plugin); - const { isLoading: isFetchLoading } = useFetchStatus(); - const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus(); - const styles = useStyles2(getStyles); - - if (isFetchLoading || isFetchDetailsLoading) { - return ( - - - - ); - } - - if (!plugin) { - return ( - - - That plugin cannot be found. Please check the url is correct or
- go to the plugin catalog. -
-
- ); - } - - return ( - - - - - - - - - - ); + return ; } - -export const getStyles = (theme: GrafanaTheme2) => { - return { - alert: css` - margin-bottom: ${theme.spacing(2)}; - `, - subtitle: css` - display: flex; - flex-direction: column; - gap: ${theme.spacing(1)}; - `, - // Needed due to block formatting context - tabContent: css` - overflow: auto; - height: 100%; - `, - }; -}; From c8c1499cd0cb76da66c8949332efe5050e5d656d Mon Sep 17 00:00:00 2001 From: George Robinson Date: Tue, 29 Nov 2022 13:15:48 +0000 Subject: [PATCH 002/168] Docs: Add docs for labels with dots (#59352) --- .../annotation-label/variables-label-annotation.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md b/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md index 4bd6e1c3a3b..e3b7be61cbb 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md +++ b/docs/sources/alerting/fundamentals/annotation-label/variables-label-annotation.md @@ -87,3 +87,17 @@ The following template variables are available when expanding labels and annotat | $labels | The labels from the query or condition. For example, `{{ $labels.instance }}` and `{{ $labels.job }}`. This is unavailable when the rule uses a [classic condition]({{< relref "../../alerting-rules/create-grafana-managed-rule/#single-and-multi-dimensional-rule" >}}). | | $values | The values of all reduce and math expressions that were evaluated for this alert rule. For example, `{{ $values.A }}`, `{{ $values.A.Labels }}` and `{{ $values.A.Value }}` where `A` is the `refID` of the reduce or math expression. If the rule uses a classic condition instead of a reduce and math expression, then `$values` contains the combination of the `refID` and position of the condition. | | $value | The value string of the alert instance. For example, `[ var='A' labels={instance=foo} value=10 ]`. | + +### Labels with dots + +If a label contains a dot (full stop or period) in its name then the following will not work: + +``` +Instance {{ $labels.instance.name }} has been down for more than 5 minutes +``` + +This is because we are printing a non-existing field `name` in `$labels.instance` rather than `instance.name` in `$labels`. Instead we can use the `index` function to print `instance.name`: + +``` +Instance {{ index $labels "instance.name" }} has been down for more than 5 minutes +``` From 1395436dce113b46ad4f39bb1b4b6bbaaeb62010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 29 Nov 2022 14:49:26 +0100 Subject: [PATCH 003/168] Scenes: Url sync (#59154) * Scene url sync * muu * Progress * Time range stuff * Progress * Progress * Adding tests * Rennamed interface * broken test * handling of unique url keys * Fixing isuse with unique key mapping and depth * Testing grid row expand sync * Updates * Switched from Map to Object * Now arrays work * Update public/app/features/scenes/core/types.ts Co-authored-by: Dominik Prokop * Update public/app/features/scenes/core/SceneTimeRange.tsx Co-authored-by: Dominik Prokop * Update public/app/features/scenes/core/SceneObjectBase.tsx Co-authored-by: Dominik Prokop Co-authored-by: Dominik Prokop --- .betterer.results | 4 - .../app/features/scenes/components/Scene.tsx | 1 + .../scenes/components/SceneTimePicker.tsx | 2 +- .../scenes/components/VizPanel/VizPanel.tsx | 2 +- .../app/features/scenes/components/index.ts | 3 +- .../layout/SceneGridLayout.test.tsx | 3 +- .../components/layout/SceneGridLayout.tsx | 111 +--------- .../scenes/components/layout/SceneGridRow.tsx | 122 ++++++++++ .../features/scenes/core/SceneObjectBase.tsx | 8 +- .../scenes/core/SceneTimeRange.test.tsx | 37 ++++ .../features/scenes/core/SceneTimeRange.tsx | 108 +++++++-- public/app/features/scenes/core/events.ts | 4 +- public/app/features/scenes/core/sceneGraph.ts | 4 +- public/app/features/scenes/core/types.ts | 32 ++- .../scenes/dashboard/DashboardScene.tsx | 22 ++ .../scenes/dashboard/DashboardsLoader.ts | 4 + .../scenes/querying/SceneQueryRunner.ts | 6 +- .../scenes/scenes/gridMultiTimeRange.tsx | 13 +- .../scenes/scenes/gridWithMultipleData.tsx | 4 +- .../features/scenes/scenes/gridWithRow.tsx | 3 +- .../features/scenes/scenes/gridWithRows.tsx | 4 +- public/app/features/scenes/scenes/nested.tsx | 2 +- .../services/SceneObjectUrlSyncConfig.ts | 30 +++ .../scenes/services/UrlSyncManager.test.ts | 209 ++++++++++++++++++ .../scenes/services/UrlSyncManager.ts | 153 ++++++++++++- public/test/setupTests.ts | 2 +- 26 files changed, 718 insertions(+), 175 deletions(-) create mode 100644 public/app/features/scenes/components/layout/SceneGridRow.tsx create mode 100644 public/app/features/scenes/core/SceneTimeRange.test.tsx create mode 100644 public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts create mode 100644 public/app/features/scenes/services/UrlSyncManager.test.ts diff --git a/.betterer.results b/.betterer.results index b903dc1d5e6..7eabbff1a7e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4556,10 +4556,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], - "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/scenes/core/sceneGraph.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index f870c91b57a..88b28df0619 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -25,6 +25,7 @@ export class Scene extends SceneObjectBase { public activate() { super.activate(); this.urlSyncManager = new UrlSyncManager(this); + this.urlSyncManager.initSync(); } public deactivate() { diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx index d850f0e769a..2f294420b0e 100644 --- a/public/app/features/scenes/components/SceneTimePicker.tsx +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -27,7 +27,7 @@ function SceneTimePickerRenderer({ model }: SceneComponentProps return ( extends SceneObjectBase< public onChangeTimeRange = (timeRange: AbsoluteTimeRange) => { const sceneTimeRange = sceneGraph.getTimeRange(this); - sceneTimeRange.setState({ + sceneTimeRange.onTimeRangeChange({ raw: { from: toUtc(timeRange.from), to: toUtc(timeRange.to), diff --git a/public/app/features/scenes/components/index.ts b/public/app/features/scenes/components/index.ts index 89aafc6064f..c99ebfbf789 100644 --- a/public/app/features/scenes/components/index.ts +++ b/public/app/features/scenes/components/index.ts @@ -7,4 +7,5 @@ export { SceneTimePicker } from './SceneTimePicker'; export { ScenePanelRepeater } from './ScenePanelRepeater'; export { SceneSubMenu } from './SceneSubMenu'; export { SceneFlexLayout } from './layout/SceneFlexLayout'; -export { SceneGridLayout, SceneGridRow } from './layout/SceneGridLayout'; +export { SceneGridLayout } from './layout/SceneGridLayout'; +export { SceneGridRow } from './layout/SceneGridRow'; diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx index 677f1a8a6d0..cc680e05d32 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.test.tsx @@ -7,7 +7,8 @@ import { SceneObjectBase } from '../../core/SceneObjectBase'; import { SceneComponentProps, SceneLayoutChildState } from '../../core/types'; import { Scene } from '../Scene'; -import { SceneGridLayout, SceneGridRow } from './SceneGridLayout'; +import { SceneGridLayout } from './SceneGridLayout'; +import { SceneGridRow } from './SceneGridRow'; // Mocking AutoSizer to allow testing of the SceneGridLayout component rendering jest.mock( diff --git a/public/app/features/scenes/components/layout/SceneGridLayout.tsx b/public/app/features/scenes/components/layout/SceneGridLayout.tsx index 6e70b2afd31..441070a79b3 100644 --- a/public/app/features/scenes/components/layout/SceneGridLayout.tsx +++ b/public/app/features/scenes/components/layout/SceneGridLayout.tsx @@ -1,23 +1,13 @@ -import { css, cx } from '@emotion/css'; import React from 'react'; import ReactGridLayout from 'react-grid-layout'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, useStyles2 } from '@grafana/ui'; import { DEFAULT_PANEL_SPAN, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { SceneObjectBase } from '../../core/SceneObjectBase'; -import { sceneGraph } from '../../core/sceneGraph'; -import { - SceneComponentProps, - SceneLayoutChild, - SceneLayoutChildState, - SceneLayoutState, - SceneObject, - SceneObjectSize, -} from '../../core/types'; -import { SceneDragHandle } from '../SceneDragHandle'; +import { SceneComponentProps, SceneLayoutChild, SceneLayoutState, SceneObjectSize } from '../../core/types'; + +import { SceneGridRow } from './SceneGridRow'; interface SceneGridLayoutState extends SceneLayoutState {} @@ -369,101 +359,6 @@ function SceneGridLayoutRenderer({ model }: SceneComponentProps ); } -interface SceneGridRowState extends SceneLayoutChildState { - title: string; - isCollapsible?: boolean; - isCollapsed?: boolean; - children: Array>; -} - -export class SceneGridRow extends SceneObjectBase { - public static Component = SceneGridRowRenderer; - - public constructor(state: SceneGridRowState) { - super({ - isResizable: false, - isDraggable: true, - isCollapsible: true, - ...state, - size: { - ...state.size, - x: 0, - height: 1, - width: GRID_COLUMN_COUNT, - }, - }); - } - - public onCollapseToggle = () => { - if (!this.state.isCollapsible) { - return; - } - - const layout = this.parent; - - if (!layout || !(layout instanceof SceneGridLayout)) { - throw new Error('SceneGridRow must be a child of SceneGridLayout'); - } - - layout.toggleRow(this); - }; -} - -function SceneGridRowRenderer({ model }: SceneComponentProps) { - const styles = useStyles2(getSceneGridRowStyles); - const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); - const layout = sceneGraph.getLayout(model); - const dragHandle = ; - - return ( -
-
-
- {isCollapsible && } - {title} -
- {isDraggable && isCollapsed &&
{dragHandle}
} -
-
- ); -} - -const getSceneGridRowStyles = (theme: GrafanaTheme2) => { - return { - row: css({ - width: '100%', - height: '100%', - position: 'relative', - zIndex: 0, - display: 'flex', - flexDirection: 'column', - }), - rowHeader: css({ - width: '100%', - height: '30px', - display: 'flex', - justifyContent: 'space-between', - marginBottom: '8px', - border: `1px solid transparent`, - }), - rowTitleWrapper: css({ - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - }), - rowHeaderCollapsed: css({ - marginBottom: '0px', - background: theme.colors.background.primary, - border: `1px solid ${theme.colors.border.weak}`, - borderRadius: theme.shape.borderRadius(1), - }), - rowTitle: css({ - fontSize: theme.typography.h6.fontSize, - fontWeight: theme.typography.h6.fontWeight, - }), - }; -}; - function validateChildrenSize(children: SceneLayoutChild[]) { if ( children.find( diff --git a/public/app/features/scenes/components/layout/SceneGridRow.tsx b/public/app/features/scenes/components/layout/SceneGridRow.tsx new file mode 100644 index 00000000000..a3572a35e0f --- /dev/null +++ b/public/app/features/scenes/components/layout/SceneGridRow.tsx @@ -0,0 +1,122 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; +import { GRID_COLUMN_COUNT } from 'app/core/constants'; + +import { SceneObjectBase } from '../../core/SceneObjectBase'; +import { sceneGraph } from '../../core/sceneGraph'; +import { SceneComponentProps, SceneLayoutChildState, SceneObject, SceneObjectUrlValues } from '../../core/types'; +import { SceneObjectUrlSyncConfig } from '../../services/SceneObjectUrlSyncConfig'; +import { SceneDragHandle } from '../SceneDragHandle'; + +import { SceneGridLayout } from './SceneGridLayout'; + +export interface SceneGridRowState extends SceneLayoutChildState { + title: string; + isCollapsible?: boolean; + isCollapsed?: boolean; + children: Array>; +} + +export class SceneGridRow extends SceneObjectBase { + public static Component = SceneGridRowRenderer; + + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['rowc'] }); + + public constructor(state: SceneGridRowState) { + super({ + isResizable: false, + isDraggable: true, + isCollapsible: true, + ...state, + size: { + ...state.size, + x: 0, + height: 1, + width: GRID_COLUMN_COUNT, + }, + }); + } + + public onCollapseToggle = () => { + if (!this.state.isCollapsible) { + return; + } + + const layout = this.parent; + + if (!layout || !(layout instanceof SceneGridLayout)) { + throw new Error('SceneGridRow must be a child of SceneGridLayout'); + } + + layout.toggleRow(this); + }; + + public getUrlState(state: SceneGridRowState) { + return { rowc: state.isCollapsed ? '1' : '0' }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { + const isCollapsed = values.rowc === '1'; + if (isCollapsed !== this.state.isCollapsed) { + this.onCollapseToggle(); + } + } +} + +export function SceneGridRowRenderer({ model }: SceneComponentProps) { + const styles = useStyles2(getSceneGridRowStyles); + const { isCollapsible, isCollapsed, isDraggable, title } = model.useState(); + const layout = sceneGraph.getLayout(model); + const dragHandle = ; + + return ( +
+
+
+ {isCollapsible && } + {title} +
+ {isDraggable && isCollapsed &&
{dragHandle}
} +
+
+ ); +} + +const getSceneGridRowStyles = (theme: GrafanaTheme2) => { + return { + row: css({ + width: '100%', + height: '100%', + position: 'relative', + zIndex: 0, + display: 'flex', + flexDirection: 'column', + }), + rowHeader: css({ + width: '100%', + height: '30px', + display: 'flex', + justifyContent: 'space-between', + marginBottom: '8px', + border: `1px solid transparent`, + }), + rowTitleWrapper: css({ + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }), + rowHeaderCollapsed: css({ + marginBottom: '0px', + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.borderRadius(1), + }), + rowTitle: css({ + fontSize: theme.typography.h6.fontSize, + fontWeight: theme.typography.h6.fontWeight, + }), + }; +}; diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx index ffb997e5a1d..cf250e26be1 100644 --- a/public/app/features/scenes/core/SceneObjectBase.tsx +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -9,7 +9,7 @@ import { SceneVariableDependencyConfigLike } from '../variables/types'; import { SceneComponentWrapper } from './SceneComponentWrapper'; import { SceneObjectStateChangedEvent } from './events'; -import { SceneObject, SceneComponent, SceneObjectState } from './types'; +import { SceneObject, SceneComponent, SceneObjectState, SceneObjectUrlSyncHandler } from './types'; import { cloneSceneObject, forEachSceneObjectInState } from './utils'; export abstract class SceneObjectBase @@ -26,6 +26,7 @@ export abstract class SceneObjectBase | undefined; public constructor(state: TState) { if (!state.key) { @@ -57,6 +58,11 @@ export abstract class SceneObjectBase | undefined { + return this._urlSync; + } + /** * Used in render functions when rendering a SceneObject. * Wraps the component in an EditWrapper that handles edit mode diff --git a/public/app/features/scenes/core/SceneTimeRange.test.tsx b/public/app/features/scenes/core/SceneTimeRange.test.tsx new file mode 100644 index 00000000000..76baeb651d1 --- /dev/null +++ b/public/app/features/scenes/core/SceneTimeRange.test.tsx @@ -0,0 +1,37 @@ +import { SceneTimeRange } from './SceneTimeRange'; + +describe('SceneTimeRange', () => { + it('when created should evaluate time range', () => { + const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); + expect(timeRange.state.value.raw.from).toBe('now-1h'); + }); + + it('when time range refreshed should evaluate and update value', async () => { + const timeRange = new SceneTimeRange({ from: 'now-30s', to: 'now' }); + const startTime = timeRange.state.value.from.valueOf(); + await new Promise((r) => setTimeout(r, 2)); + timeRange.onRefresh(); + const diff = timeRange.state.value.from.valueOf() - startTime; + expect(diff).toBeGreaterThan(1); + expect(diff).toBeLessThan(100); + }); + + it('toUrlValues with relative range', () => { + const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); + expect(timeRange.urlSync?.getUrlState(timeRange.state)).toEqual({ + from: 'now-1h', + to: 'now', + }); + }); + + it('updateFromUrl with ISO time', () => { + const timeRange = new SceneTimeRange({ from: 'now-1h', to: 'now' }); + timeRange.urlSync?.updateFromUrl({ + from: '2021-01-01T10:00:00.000Z', + to: '2021-02-03T01:20:00.000Z', + }); + + expect(timeRange.state.from).toEqual('2021-01-01T10:00:00.000Z'); + expect(timeRange.state.value.from.valueOf()).toEqual(1609495200000); + }); +}); diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx index c790d5f739c..272ac46c6af 100644 --- a/public/app/features/scenes/core/SceneTimeRange.tsx +++ b/public/app/features/scenes/core/SceneTimeRange.tsx @@ -1,37 +1,107 @@ -import { getDefaultTimeRange, getTimeZone, TimeRange, UrlQueryMap } from '@grafana/data'; +import { dateMath, getTimeZone, TimeRange, TimeZone, toUtc } from '@grafana/data'; + +import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig'; import { SceneObjectBase } from './SceneObjectBase'; -import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types'; +import { SceneTimeRangeLike, SceneTimeRangeState, SceneObjectUrlValues, SceneObjectUrlValue } from './types'; + +export class SceneTimeRange extends SceneObjectBase implements SceneTimeRangeLike { + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['from', 'to'] }); -export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync { public constructor(state: Partial = {}) { - super({ - ...getDefaultTimeRange(), - timeZone: getTimeZone(), - ...state, - }); + const from = state.from ?? 'now-6h'; + const to = state.to ?? 'now'; + const timeZone = state.timeZone ?? getTimeZone(); + const value = evaluateTimeRange(from, to, timeZone); + super({ from, to, timeZone, value, ...state }); } public onTimeRangeChange = (timeRange: TimeRange) => { - this.setState(timeRange); + const update: Partial = {}; + + if (typeof timeRange.raw.from === 'string') { + update.from = timeRange.raw.from; + } else { + update.from = timeRange.raw.from.toISOString(); + } + + if (typeof timeRange.raw.to === 'string') { + update.to = timeRange.raw.to; + } else { + update.to = timeRange.raw.to.toISOString(); + } + + update.value = evaluateTimeRange(update.from, update.to, this.state.timeZone); + this.setState(update); }; public onRefresh = () => { - // TODO re-eval time range - this.setState({ ...this.state }); + this.setState({ value: evaluateTimeRange(this.state.from, this.state.to, this.state.timeZone) }); }; public onIntervalChanged = (_: string) => {}; - /** These url sync functions are only placeholders for something more sophisticated */ - public getUrlState() { - return { - from: this.state.raw.from, - to: this.state.raw.to, - } as any; + public getUrlState(state: SceneTimeRangeState) { + return { from: state.from, to: state.to }; } - public updateFromUrl(values: UrlQueryMap) { - // TODO + public updateFromUrl(values: SceneObjectUrlValues) { + const update: Partial = {}; + + const from = parseUrlParam(values.from); + if (from) { + update.from = from; + } + + const to = parseUrlParam(values.to); + if (to) { + update.to = to; + } + + update.value = evaluateTimeRange(update.from ?? this.state.from, update.to ?? this.state.to, this.state.timeZone); + this.setState(update); } } + +function parseUrlParam(value: SceneObjectUrlValue): string | null { + if (typeof value !== 'string') { + return null; + } + + if (value.indexOf('now') !== -1) { + return value; + } + + if (value.length === 8) { + const utcValue = toUtc(value, 'YYYYMMDD'); + if (utcValue.isValid()) { + return utcValue.toISOString(); + } + } else if (value.length === 15) { + const utcValue = toUtc(value, 'YYYYMMDDTHHmmss'); + if (utcValue.isValid()) { + return utcValue.toISOString(); + } + } else if (value.length === 24) { + const utcValue = toUtc(value); + return utcValue.toISOString(); + } + + const epoch = parseInt(value, 10); + if (!isNaN(epoch)) { + return toUtc(epoch).toISOString(); + } + + return null; +} + +function evaluateTimeRange(from: string, to: string, timeZone: TimeZone, fiscalYearStartMonth?: number): TimeRange { + return { + from: dateMath.parse(from, false, timeZone, fiscalYearStartMonth)!, + to: dateMath.parse(to, true, timeZone, fiscalYearStartMonth)!, + raw: { + from: from, + to: to, + }, + }; +} diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts index 650bac14759..6d9347b8404 100644 --- a/public/app/features/scenes/core/events.ts +++ b/public/app/features/scenes/core/events.ts @@ -1,12 +1,12 @@ import { BusEventWithPayload } from '@grafana/data'; -import { SceneObject, SceneObjectState, SceneObjectWithUrlSync } from './types'; +import { SceneObject, SceneObjectState } from './types'; export interface SceneObjectStateChangedPayload { prevState: SceneObjectState; newState: SceneObjectState; partialUpdate: Partial; - changedObject: SceneObject | SceneObjectWithUrlSync; + changedObject: SceneObject; } export class SceneObjectStateChangedEvent extends BusEventWithPayload { diff --git a/public/app/features/scenes/core/sceneGraph.ts b/public/app/features/scenes/core/sceneGraph.ts index 2522e2e5264..eb7279a1c68 100644 --- a/public/app/features/scenes/core/sceneGraph.ts +++ b/public/app/features/scenes/core/sceneGraph.ts @@ -6,7 +6,7 @@ import { SceneVariables } from '../variables/types'; import { SceneDataNode } from './SceneDataNode'; import { SceneTimeRange as SceneTimeRangeImpl } from './SceneTimeRange'; -import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRange } from './types'; +import { SceneDataState, SceneEditor, SceneLayoutState, SceneObject, SceneTimeRangeLike } from './types'; /** * Get the closest node with variables @@ -42,7 +42,7 @@ export function getData(sceneObject: SceneObject): SceneObject { /** * Will walk up the scene object graph to the closest $timeRange scene object */ -export function getTimeRange(sceneObject: SceneObject): SceneTimeRange { +export function getTimeRange(sceneObject: SceneObject): SceneTimeRangeLike { const { $timeRange } = sceneObject.state; if ($timeRange) { return $timeRange; diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts index 3363da75d0d..4520e0f2def 100644 --- a/public/app/features/scenes/core/types.ts +++ b/public/app/features/scenes/core/types.ts @@ -1,13 +1,13 @@ import React from 'react'; import { Observer, Subscription, Unsubscribable } from 'rxjs'; -import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone, UrlQueryMap } from '@grafana/data'; +import { BusEvent, BusEventHandler, BusEventType, PanelData, TimeRange, TimeZone } from '@grafana/data'; import { SceneVariableDependencyConfigLike, SceneVariables } from '../variables/types'; export interface SceneObjectStatePlain { key?: string; - $timeRange?: SceneTimeRange; + $timeRange?: SceneTimeRangeLike; $data?: SceneObject; $editor?: SceneEditor; $variables?: SceneVariables; @@ -19,8 +19,6 @@ export interface SceneLayoutChildSize { export interface SceneLayoutChildInteractions { isDraggable?: boolean; isResizable?: boolean; - isCollapsible?: boolean; - isCollapsed?: boolean; } export interface SceneLayoutChildState @@ -65,6 +63,9 @@ export interface SceneObject /** This abtractions declares what variables the scene object depends on and how to handle when they change value. **/ readonly variableDependency?: SceneVariableDependencyConfigLike; + /** This abstraction declares URL sync dependencies of a scene object. **/ + readonly urlSync?: SceneObjectUrlSyncHandler; + /** Subscribe to state changes */ subscribeToState(observer?: Partial>): Subscription; @@ -128,11 +129,15 @@ interface SceneComponentEditWrapperProps { children: React.ReactNode; } -export interface SceneTimeRangeState extends SceneObjectStatePlain, TimeRange { +export interface SceneTimeRangeState extends SceneObjectStatePlain { + from: string; + to: string; timeZone: TimeZone; + fiscalYearStartMonth?: number; + value: TimeRange; } -export interface SceneTimeRange extends SceneObject { +export interface SceneTimeRangeLike extends SceneObject { onTimeRangeChange(timeRange: TimeRange): void; onIntervalChanged(interval: string): void; onRefresh(): void; @@ -147,7 +152,16 @@ export function isSceneObject(obj: any): obj is SceneObject { } /** These functions are still just temporary until this get's refined */ -export interface SceneObjectWithUrlSync extends SceneObject { - getUrlState(): UrlQueryMap; - updateFromUrl(values: UrlQueryMap): void; +export interface SceneObjectWithUrlSync extends SceneObject { + getUrlState(state: TState): SceneObjectUrlValues; + updateFromUrl(values: SceneObjectUrlValues): void; } + +export interface SceneObjectUrlSyncHandler { + getKeys(): Set; + getUrlState(state: TState): SceneObjectUrlValues; + updateFromUrl(values: SceneObjectUrlValues): void; +} + +export type SceneObjectUrlValue = string | string[] | undefined | null; +export type SceneObjectUrlValues = Record; diff --git a/public/app/features/scenes/dashboard/DashboardScene.tsx b/public/app/features/scenes/dashboard/DashboardScene.tsx index 46106a06a85..b6d23c8ee3d 100644 --- a/public/app/features/scenes/dashboard/DashboardScene.tsx +++ b/public/app/features/scenes/dashboard/DashboardScene.tsx @@ -8,6 +8,7 @@ import { Page } from 'app/core/components/Page/Page'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneLayout, SceneObject, SceneObjectStatePlain } from '../core/types'; +import { UrlSyncManager } from '../services/UrlSyncManager'; interface DashboardSceneState extends SceneObjectStatePlain { title: string; @@ -18,6 +19,27 @@ interface DashboardSceneState extends SceneObjectStatePlain { export class DashboardScene extends SceneObjectBase { public static Component = DashboardSceneRenderer; + private urlSyncManager?: UrlSyncManager; + + public activate() { + super.activate(); + } + + /** + * It's better to do this before activate / mount to not trigger unnessary re-renders + */ + public initUrlSync() { + this.urlSyncManager = new UrlSyncManager(this); + this.urlSyncManager.initSync(); + } + + public deactivate() { + super.deactivate(); + + if (this.urlSyncManager) { + this.urlSyncManager!.cleanUp(); + } + } } function DashboardSceneRenderer({ model }: SceneComponentProps) { diff --git a/public/app/features/scenes/dashboard/DashboardsLoader.ts b/public/app/features/scenes/dashboard/DashboardsLoader.ts index 94197ca42d3..4566816e31d 100644 --- a/public/app/features/scenes/dashboard/DashboardsLoader.ts +++ b/public/app/features/scenes/dashboard/DashboardsLoader.ts @@ -55,6 +55,10 @@ export class DashboardLoader extends StateManagerBase { actions: [new SceneTimePicker({})], }); + // We initialize URL sync here as it better to do that before mounting and doing any rendering. + // But would be nice to have a conditional around this so you can pre-load dashboards without url sync. + dashboard.initUrlSync(); + this.cache[rsp.dashboard.uid] = dashboard; this.setState({ dashboard, isLoading: false }); } diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts index bd93f611f42..3c11b50cac8 100644 --- a/public/app/features/scenes/querying/SceneQueryRunner.ts +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -52,7 +52,7 @@ export class SceneQueryRunner extends SceneObjectBase { this._subs.add( timeRange.subscribeToState({ next: (timeRange) => { - this.runWithTimeRange(timeRange); + this.runWithTimeRange(timeRange.value); }, }) ); @@ -88,7 +88,7 @@ export class SceneQueryRunner extends SceneObjectBase { public setContainerWidth(width: number) { // If we don't have a width we should run queries - if (!this._containerWidth) { + if (!this._containerWidth && width > 0) { this._containerWidth = width; // If we don't have maxDataPoints specifically set and maxDataPointsFromWidth is true @@ -108,7 +108,7 @@ export class SceneQueryRunner extends SceneObjectBase { public runQueries() { const timeRange = sceneGraph.getTimeRange(this); - this.runWithTimeRange(timeRange.state); + this.runWithTimeRange(timeRange.state.value); } private getMaxDataPoints() { diff --git a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx index 8ed865aa812..169339a3156 100644 --- a/public/app/features/scenes/scenes/gridMultiTimeRange.tsx +++ b/public/app/features/scenes/scenes/gridMultiTimeRange.tsx @@ -1,9 +1,7 @@ -import { dateTime } from '@grafana/data'; - -import { VizPanel } from '../components'; +import { VizPanel, SceneGridRow } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; @@ -11,12 +9,9 @@ import { getQueryRunnerWithRandomWalkQuery } from './queries'; export function getGridWithMultipleTimeRanges(): Scene { const globalTimeRange = new SceneTimeRange(); - - const now = dateTime(); const row1TimeRange = new SceneTimeRange({ - from: dateTime(now).subtract(1, 'year'), - to: now, - raw: { from: 'now-1y', to: 'now' }, + from: 'now-1y', + to: 'now', }); const scene = new Scene({ diff --git a/public/app/features/scenes/scenes/gridWithMultipleData.tsx b/public/app/features/scenes/scenes/gridWithMultipleData.tsx index 43960d9b6da..5d2e1a893ab 100644 --- a/public/app/features/scenes/scenes/gridWithMultipleData.tsx +++ b/public/app/features/scenes/scenes/gridWithMultipleData.tsx @@ -1,7 +1,7 @@ -import { VizPanel } from '../components'; +import { VizPanel, SceneGridRow } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; diff --git a/public/app/features/scenes/scenes/gridWithRow.tsx b/public/app/features/scenes/scenes/gridWithRow.tsx index 85826d52b5b..d333ee167fb 100644 --- a/public/app/features/scenes/scenes/gridWithRow.tsx +++ b/public/app/features/scenes/scenes/gridWithRow.tsx @@ -1,7 +1,6 @@ -import { VizPanel } from '../components'; +import { VizPanel, SceneGridLayout, SceneGridRow } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; -import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; diff --git a/public/app/features/scenes/scenes/gridWithRows.tsx b/public/app/features/scenes/scenes/gridWithRows.tsx index 1aef68fb5d4..a7fa3aa7282 100644 --- a/public/app/features/scenes/scenes/gridWithRows.tsx +++ b/public/app/features/scenes/scenes/gridWithRows.tsx @@ -1,8 +1,8 @@ -import { VizPanel } from '../components'; +import { VizPanel, SceneGridRow } from '../components'; import { Scene } from '../components/Scene'; import { SceneTimePicker } from '../components/SceneTimePicker'; import { SceneFlexLayout } from '../components/layout/SceneFlexLayout'; -import { SceneGridLayout, SceneGridRow } from '../components/layout/SceneGridLayout'; +import { SceneGridLayout } from '../components/layout/SceneGridLayout'; import { SceneTimeRange } from '../core/SceneTimeRange'; import { SceneEditManager } from '../editor/SceneEditManager'; diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx index 728346f4138..05fb096b339 100644 --- a/public/app/features/scenes/scenes/nested.tsx +++ b/public/app/features/scenes/scenes/nested.tsx @@ -13,12 +13,12 @@ export function getNestedScene(): Scene { layout: new SceneFlexLayout({ direction: 'column', children: [ - getInnerScene('Inner scene'), new VizPanel({ key: '3', pluginId: 'timeseries', title: 'Panel 3', }), + getInnerScene('Inner scene'), ], }), $timeRange: new SceneTimeRange(), diff --git a/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts b/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts new file mode 100644 index 00000000000..81c926b086b --- /dev/null +++ b/public/app/features/scenes/services/SceneObjectUrlSyncConfig.ts @@ -0,0 +1,30 @@ +import { + SceneObjectState, + SceneObjectUrlSyncHandler, + SceneObjectWithUrlSync, + SceneObjectUrlValues, +} from '../core/types'; + +interface SceneObjectUrlSyncConfigOptions { + keys?: string[]; +} + +export class SceneObjectUrlSyncConfig implements SceneObjectUrlSyncHandler { + private _keys: Set; + + public constructor(private _sceneObject: SceneObjectWithUrlSync, _options: SceneObjectUrlSyncConfigOptions) { + this._keys = new Set(_options.keys); + } + + public getKeys(): Set { + return this._keys; + } + + public getUrlState(state: TState): SceneObjectUrlValues { + return this._sceneObject.getUrlState(state); + } + + public updateFromUrl(values: SceneObjectUrlValues): void { + this._sceneObject.updateFromUrl(values); + } +} diff --git a/public/app/features/scenes/services/UrlSyncManager.test.ts b/public/app/features/scenes/services/UrlSyncManager.test.ts new file mode 100644 index 00000000000..165346ca1fb --- /dev/null +++ b/public/app/features/scenes/services/UrlSyncManager.test.ts @@ -0,0 +1,209 @@ +import { Location } from 'history'; + +import { locationService } from '@grafana/runtime'; + +import { SceneFlexLayout } from '../components'; +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneLayoutChildState, SceneObjectUrlValues } from '../core/types'; + +import { SceneObjectUrlSyncConfig } from './SceneObjectUrlSyncConfig'; +import { isUrlValueEqual, UrlSyncManager } from './UrlSyncManager'; + +interface TestObjectState extends SceneLayoutChildState { + name: string; + array?: string[]; + other?: string; +} + +class TestObj extends SceneObjectBase { + protected _urlSync = new SceneObjectUrlSyncConfig(this, { + keys: ['name', 'array'], + }); + + public getUrlState(state: TestObjectState) { + return { name: state.name, array: state.array }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { + if (typeof values.name === 'string') { + this.setState({ name: values.name ?? 'NA' }); + } + if (Array.isArray(values.array)) { + this.setState({ array: values.array }); + } + } +} + +describe('UrlSyncManager', () => { + let urlManager: UrlSyncManager; + let locationUpdates: Location[] = []; + let listenUnregister: () => void; + + beforeEach(() => { + locationUpdates = []; + listenUnregister = locationService.getHistory().listen((location) => { + locationUpdates.push(location); + }); + }); + + afterEach(() => { + urlManager.cleanUp(); + locationService.push('/'); + listenUnregister(); + }); + + describe('When state changes', () => { + it('should update url', () => { + const obj = new TestObj({ name: 'test' }); + const scene = new SceneFlexLayout({ + children: [obj], + }); + + urlManager = new UrlSyncManager(scene); + + // When making state change + obj.setState({ name: 'test2' }); + + // Should update url + const searchObj = locationService.getSearchObject(); + expect(searchObj.name).toBe('test2'); + + // When making unrelated state change + obj.setState({ other: 'not synced' }); + + // Should not update url + expect(locationUpdates.length).toBe(1); + + // When clearing url (via go back) + locationService.getHistory().goBack(); + + // Should restore to initial state + expect(obj.state.name).toBe('test'); + }); + }); + + describe('When url changes', () => { + it('should update state', () => { + const obj = new TestObj({ name: 'test' }); + const initialObjState = obj.state; + const scene = new SceneFlexLayout({ + children: [obj], + }); + + urlManager = new UrlSyncManager(scene); + + // When non relevant key changes in url + locationService.partial({ someOtherProp: 'test2' }); + // Should not affect state + expect(obj.state).toBe(initialObjState); + + // When relevant key changes in url + locationService.partial({ name: 'test2' }); + // Should update state + expect(obj.state.name).toBe('test2'); + + // When relevant key is cleared (say go back) + locationService.partial({ name: null }); + // Should revert to initial state + expect(obj.state.name).toBe('test'); + + // When relevant key is set to current state + const currentState = obj.state; + locationService.partial({ name: currentState.name }); + // Should not affect state (same instance) + expect(obj.state).toBe(currentState); + }); + }); + + describe('When multiple scene objects wants to set same url keys', () => { + it('should give each object a unique key', () => { + const outerTimeRange = new SceneTimeRange(); + const innerTimeRange = new SceneTimeRange(); + + const scene = new SceneFlexLayout({ + children: [ + new SceneFlexLayout({ + $timeRange: innerTimeRange, + children: [], + }), + ], + $timeRange: outerTimeRange, + }); + + urlManager = new UrlSyncManager(scene); + + // When making state changes for second object with same key + innerTimeRange.setState({ from: 'now-10m' }); + + // Should use unique key based where it is in the scene + expect(locationService.getSearchObject()).toEqual({ + ['from-2']: 'now-10m', + ['to-2']: 'now', + }); + + outerTimeRange.setState({ from: 'now-20m' }); + + // Should not suffix key for first object + expect(locationService.getSearchObject()).toEqual({ + from: 'now-20m', + to: 'now', + ['from-2']: 'now-10m', + ['to-2']: 'now', + }); + + // When updating via url + locationService.partial({ ['from-2']: 'now-10s' }); + // should find the correct object + expect(innerTimeRange.state.from).toBe('now-10s'); + // should not update the first object + expect(outerTimeRange.state.from).toBe('now-20m'); + // Should not cause another url update + expect(locationUpdates.length).toBe(3); + }); + }); + + describe('When updating array value', () => { + it('Should update url correctly', () => { + const obj = new TestObj({ name: 'test' }); + const scene = new SceneFlexLayout({ + children: [obj], + }); + + urlManager = new UrlSyncManager(scene); + + // When making state change + obj.setState({ array: ['A', 'B'] }); + + // Should update url + const searchObj = locationService.getSearchObject(); + expect(searchObj.array).toEqual(['A', 'B']); + + // When making unrelated state change + obj.setState({ other: 'not synced' }); + + // Should not update url + expect(locationUpdates.length).toBe(1); + + // When updating via url + locationService.partial({ array: ['A', 'B', 'C'] }); + // Should update state + expect(obj.state.array).toEqual(['A', 'B', 'C']); + }); + }); +}); + +describe('isUrlValueEqual', () => { + it('should handle all cases', () => { + expect(isUrlValueEqual([], [])).toBe(true); + expect(isUrlValueEqual([], undefined)).toBe(true); + expect(isUrlValueEqual([], null)).toBe(true); + + expect(isUrlValueEqual(['asd'], 'asd')).toBe(true); + expect(isUrlValueEqual(['asd'], ['asd'])).toBe(true); + expect(isUrlValueEqual(['asd', '2'], ['asd', '2'])).toBe(true); + + expect(isUrlValueEqual(['asd', '2'], 'asd')).toBe(false); + expect(isUrlValueEqual(['asd2'], 'asd')).toBe(false); + }); +}); diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts index 23c5ad7e184..65a361da0a7 100644 --- a/public/app/features/scenes/services/UrlSyncManager.ts +++ b/public/app/features/scenes/services/UrlSyncManager.ts @@ -1,30 +1,70 @@ import { Location } from 'history'; +import { isEqual } from 'lodash'; import { Unsubscribable } from 'rxjs'; import { locationService } from '@grafana/runtime'; import { SceneObjectStateChangedEvent } from '../core/events'; -import { SceneObject } from '../core/types'; +import { SceneObject, SceneObjectUrlValue, SceneObjectUrlValues } from '../core/types'; +import { forEachSceneObjectInState } from '../core/utils'; export class UrlSyncManager { private locationListenerUnsub: () => void; private stateChangeSub: Unsubscribable; + private initialStates: Map = new Map(); + private urlKeyMapper = new UniqueUrlKeyMapper(); - public constructor(sceneRoot: SceneObject) { + public constructor(private sceneRoot: SceneObject) { this.stateChangeSub = sceneRoot.subscribeToEvent(SceneObjectStateChangedEvent, this.onStateChanged); this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate); } + /** + * Updates the current scene state to match URL state. + */ + public initSync() { + const urlParams = locationService.getSearch(); + this.urlKeyMapper.rebuldIndex(this.sceneRoot); + this.syncSceneStateFromUrl(this.sceneRoot, urlParams); + } + private onLocationUpdate = (location: Location) => { - // TODO: find any scene object whose state we need to update + const urlParams = new URLSearchParams(location.search); + // Rebuild key mapper index before starting sync + this.urlKeyMapper.rebuldIndex(this.sceneRoot); + // Sync scene state tree from url + this.syncSceneStateFromUrl(this.sceneRoot, urlParams); }; private onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => { const changedObject = payload.changedObject; - if ('getUrlState' in changedObject) { - const urlUpdate = changedObject.getUrlState(); - locationService.partial(urlUpdate, true); + if (changedObject.urlSync) { + const newUrlState = changedObject.urlSync.getUrlState(payload.newState); + const prevUrlState = changedObject.urlSync.getUrlState(payload.prevState); + + const searchParams = locationService.getSearch(); + const mappedUpdated: SceneObjectUrlValues = {}; + + this.urlKeyMapper.rebuldIndex(this.sceneRoot); + + for (const [key, newUrlValue] of Object.entries(newUrlState)) { + const uniqueKey = this.urlKeyMapper.getUniqueKey(key, changedObject); + const currentUrlValue = searchParams.getAll(uniqueKey); + + if (!isUrlValueEqual(currentUrlValue, newUrlValue)) { + mappedUpdated[uniqueKey] = newUrlValue; + + // Remember the initial state so we can go back to it + if (!this.initialStates.has(uniqueKey) && prevUrlState[key] !== undefined) { + this.initialStates.set(uniqueKey, prevUrlState[key]); + } + } + } + + if (Object.keys(mappedUpdated).length > 0) { + locationService.partial(mappedUpdated, false); + } } }; @@ -32,4 +72,105 @@ export class UrlSyncManager { this.stateChangeSub.unsubscribe(); this.locationListenerUnsub(); } + + private syncSceneStateFromUrl(sceneObject: SceneObject, urlParams: URLSearchParams) { + if (sceneObject.urlSync) { + const urlState: SceneObjectUrlValues = {}; + const currentState = sceneObject.urlSync.getUrlState(sceneObject.state); + + for (const key of sceneObject.urlSync.getKeys()) { + const uniqueKey = this.urlKeyMapper.getUniqueKey(key, sceneObject); + const newValue = urlParams.getAll(uniqueKey); + const currentValue = currentState[key]; + + if (isUrlValueEqual(newValue, currentValue)) { + continue; + } + + if (newValue.length > 0) { + if (Array.isArray(currentValue)) { + urlState[key] = newValue; + } else { + urlState[key] = newValue[0]; + } + + // Remember the initial state so we can go back to it + if (!this.initialStates.has(uniqueKey) && currentValue !== undefined) { + this.initialStates.set(uniqueKey, currentValue); + } + } else { + const initialValue = this.initialStates.get(uniqueKey); + if (initialValue !== undefined) { + urlState[key] = initialValue; + } + } + } + + if (Object.keys(urlState).length > 0) { + sceneObject.urlSync.updateFromUrl(urlState); + } + } + + forEachSceneObjectInState(sceneObject.state, (obj) => this.syncSceneStateFromUrl(obj, urlParams)); + } +} + +interface SceneObjectWithDepth { + sceneObject: SceneObject; + depth: number; +} +class UniqueUrlKeyMapper { + private index = new Map(); + + public getUniqueKey(key: string, obj: SceneObject) { + const objectsWithKey = this.index.get(key); + if (!objectsWithKey) { + throw new Error("Cannot find any scene object that uses the key '" + key + "'"); + } + + const address = objectsWithKey.findIndex((o) => o.sceneObject === obj); + if (address > 0) { + return `${key}-${address + 1}`; + } + + return key; + } + + public rebuldIndex(root: SceneObject) { + this.index.clear(); + this.buildIndex(root, 0); + } + + private buildIndex(sceneObject: SceneObject, depth: number) { + if (sceneObject.urlSync) { + for (const key of sceneObject.urlSync.getKeys()) { + const hit = this.index.get(key); + if (hit) { + hit.push({ sceneObject, depth }); + hit.sort((a, b) => a.depth - b.depth); + } else { + this.index.set(key, [{ sceneObject, depth }]); + } + } + } + + forEachSceneObjectInState(sceneObject.state, (obj) => this.buildIndex(obj, depth + 1)); + } +} + +export function isUrlValueEqual(currentUrlValue: string[], newUrlValue: SceneObjectUrlValue): boolean { + if (currentUrlValue.length === 0 && newUrlValue == null) { + return true; + } + + if (!Array.isArray(newUrlValue) && currentUrlValue?.length === 1) { + return newUrlValue === currentUrlValue[0]; + } + + if (newUrlValue?.length === 0 && currentUrlValue === null) { + return true; + } + + // We have two arrays, lets compare them + return isEqual(currentUrlValue, newUrlValue); } diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index ac9da47b115..20052ded8bc 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -6,7 +6,7 @@ import { initReactI18next } from 'react-i18next'; import { matchers } from './matchers'; failOnConsole({ - shouldFailOnLog: true, + //shouldFailOnLog: true, }); expect.extend(matchers); From 37c14bd6bd6891f6cfcbe01ef0fbc904b17d3fde Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 29 Nov 2022 14:02:11 +0000 Subject: [PATCH 004/168] Internationalization: Preferences documentation (#59203) * I18n: Preferences documentation * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/administration/organization-preferences/index.md Co-authored-by: Ursula Kallio * remove api spec Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Ursula Kallio --- .../organization-preferences/index.md | 35 ++++++++++++++++++- .../user-management/user-preferences/index.md | 5 ++- .../setup-grafana/configure-grafana/_index.md | 4 +++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index ccd7c593060..91adba509de 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -101,7 +101,7 @@ Here is an example of the light theme. ### Change server UI theme -Grafana server administrators can change the Grafana UI theme for all users on the server by setting the [default_theme]({{< relref "../../setup-grafana/configure-grafana/#default-theme" >}}) option in the Grafana configuration file. +As a Grafana server administrator, you can change the default Grafana UI theme for all users who are on the server by setting the [default_theme]({{< relref "../../setup-grafana/configure-grafana/#default-theme" >}}) option in the Grafana configuration file. To see what the current settings are, refer to [View server settings]({{< relref "../stats-and-license#view-server-settings" >}}). @@ -237,3 +237,36 @@ You can choose your own personal home dashboard. This setting overrides all home 1. On the left menu, hover your cursor over your avatar and then click **Preferences**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. + +## Change Grafana language + +### Change server language + +Grafana server administrators can change the default Grafana UI language for all users on the server by setting the [default_language]({{< relref "../../setup-grafana/configure-grafana/#default-language" >}}) option in the Grafana configuration file. + +### Change organization language + +Organization administrators can change the language for all users in an organization. + +1. Hover your cursor over the **Configuration** (gear) icon. +1. Click **Preferences**. +1. In the Preferences section, select the **Language**. +1. Click **Save**. + +### Change team language + +Organization and team administrators can change the language for all users in a team. + +1. Hover your cursor over the **Configuration** (gear) icon in the side menu. +1. Click **Teams**. Grafana displays the team list. +1. Click on the team that you want to change the language for and then navigate to the **Settings** tab. +1. In the Preferences section, select the **Language**. +1. Click **Save**. + +### Change your personal language + +You can change the language for your user account. This setting overrides language settings at higher levels. + +1. On the left menu, hover your cursor over your avatar and then click **Preferences**. +1. In the Preferences section, select the **language**. +1. Click **Save**. diff --git a/docs/sources/administration/user-management/user-preferences/index.md b/docs/sources/administration/user-management/user-preferences/index.md index 3f4cc1e0189..3b654836ffb 100644 --- a/docs/sources/administration/user-management/user-preferences/index.md +++ b/docs/sources/administration/user-management/user-preferences/index.md @@ -29,9 +29,7 @@ You can change your Grafana password at any time. 1. Sign in to Grafana. 1. Hover your mouse over the user icon in the lower-left corner of the page. -1. Click **Change Password**. - Grafana opens the **Change Password** tab. - +1. Click **Change Password**. Grafana opens the **Change Password** tab. 1. Enter your old password and a new password. 1. Confirm your new password. 1. Click **Change Password**. @@ -54,6 +52,7 @@ You can choose the way you would like data to appear in Grafana, including the U - **Home dashboard** refers to the dashboard you see when you sign in to Grafana. By default, this is set to the Home dashboard. - **Timezone** is used by dashboards when you set time ranges, so that you view data in your timezone instead of UTC. - **Week start** is the first day of the week you want to use in dashboard time ranges, for example, `This week`. +- **Language** determines the language used for parts of the Grafana interface. **To edit your preferences**: diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index bf938890362..542f006729f 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -760,6 +760,10 @@ Text used as placeholder text on login page for password input. Set the default UI theme: `dark` or `light`. Default is `dark`. +### default_language + +This setting configures the default UI language, which must be a supported IETF language tag, such as `en-US`. + ### home_page Path to a custom home page. Users are only redirected to this if the default home dashboard is used. It should match a frontend route and contain a leading slash. From 6b5ebf2b4b0d7e083f5766e35ba29480bd7ea3d3 Mon Sep 17 00:00:00 2001 From: Gareth Dawson Date: Tue, 29 Nov 2022 14:07:34 +0000 Subject: [PATCH 005/168] Loki: Add improvements to loki label browser (#59387) * improvements * refactor label browser modal * feat(label-browser-modal): fetch labels on modal open * apply suggestions * check for log labels after languageProvider start Co-authored-by: Matias Chomicki --- .../loki/components/LokiQueryEditor.tsx | 51 ++++------------- .../components/LabelBrowserModal.test.tsx | 10 +++- .../components/LabelBrowserModal.tsx | 57 ++++++++++++------- 3 files changed, 55 insertions(+), 63 deletions(-) diff --git a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx index e33a4c27359..04ca1cbba03 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryEditor.tsx @@ -31,7 +31,6 @@ export const LokiQueryEditor = React.memo((props) => { const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false); const [dataIsStale, setDataIsStale] = useState(false); const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); - const [labelsLoaded, setLabelsLoaded] = useState(false); const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey); const query = getQueryWithDefaults(props.query); @@ -73,30 +72,10 @@ export const LokiQueryEditor = React.memo((props) => { onChange(query); }; - const onClickChooserButton = () => { + const onClickLabelBrowserButton = () => { setLabelBrowserVisible((visible) => !visible); }; - const getChooserText = (logLabelsLoaded: boolean, hasLogLabels: boolean) => { - if (!logLabelsLoaded) { - return 'Loading labels...'; - } - if (!hasLogLabels) { - return '(No labels found)'; - } - return 'Label browser'; - }; - - useEffect(() => { - datasource.languageProvider.start().then(() => { - setLabelsLoaded(true); - }); - }, [datasource]); - - const hasLogLabels = datasource.languageProvider.getLabelKeys().length > 0; - const labelBrowserText = getChooserText(labelsLoaded, hasLogLabels); - const buttonDisabled = !(labelsLoaded && hasLogLabels); - return ( <> ((props) => { onChange={onChange} onAddQuery={onAddQuery} /> + setLabelBrowserVisible(false)} + onChange={onChangeInternal} + onRunQuery={onRunQuery} + /> - setLabelBrowserVisible(false)} - onChange={onChangeInternal} - onRunQuery={onRunQuery} - /> - diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx index 3f95c0b1bc6..e36470b7d36 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.test.tsx @@ -20,7 +20,7 @@ describe('LabelBrowserModal', () => { props = { isOpen: true, - languageProvider: datasource.languageProvider, + datasource: datasource, query: {} as LokiQuery, onClose: jest.fn(), onChange: jest.fn(), @@ -30,13 +30,17 @@ describe('LabelBrowserModal', () => { jest.spyOn(datasource, 'metadataRequest').mockResolvedValue({}); }); - it('renders the label browser modal when open', () => { + it('renders the label browser modal when open', async () => { render(); + + expect(await screen.findByText(/Loading/)).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /label browser/i })).toBeInTheDocument(); }); - it("doesn't render the label browser modal when closed", () => { + it("doesn't render the label browser modal when closed", async () => { render(); + expect(screen.queryByRole('heading', { name: /label browser/i })).toBeNull(); }); }); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx index 4a0b0ba6bdd..7e86717196d 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LabelBrowserModal.tsx @@ -1,16 +1,16 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { CoreApp } from '@grafana/data'; -import { Modal } from '@grafana/ui'; +import { LoadingPlaceholder, Modal } from '@grafana/ui'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; -import LanguageProvider from '../../LanguageProvider'; import { LokiLabelBrowser } from '../../components/LokiLabelBrowser'; +import { LokiDatasource } from '../../datasource'; import { LokiQuery } from '../../types'; export interface Props { isOpen: boolean; - languageProvider: LanguageProvider; + datasource: LokiDatasource; query: LokiQuery; app?: CoreApp; onClose: () => void; @@ -19,13 +19,24 @@ export interface Props { } export const LabelBrowserModal = (props: Props) => { - const { isOpen, onClose, languageProvider, app } = props; - + const { isOpen, onClose, datasource, app } = props; + const [labelsLoaded, setLabelsLoaded] = useState(false); + const [hasLogLabels, setHasLogLabels] = useState(false); const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; + useEffect(() => { + if (!isOpen) { + return; + } + + datasource.languageProvider.start().then(() => { + setLabelsLoaded(true); + setHasLogLabels(datasource.languageProvider.getLabelKeys().length > 0); + }); + }, [datasource, isOpen]); + const changeQuery = (value: string) => { const { query, onChange, onRunQuery } = props; - const nextQuery = { ...query, expr: value }; onChange(nextQuery); onRunQuery(); @@ -38,20 +49,24 @@ export const LabelBrowserModal = (props: Props) => { return ( - storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> - {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { - return ( - - ); - }} - + {!labelsLoaded && } + {labelsLoaded && !hasLogLabels &&

No labels found.

} + {labelsLoaded && hasLogLabels && ( + storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> + {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { + return ( + + ); + }} + + )}
); }; From 5b7ef923995fce809959080f5aeed7c137a8ddee Mon Sep 17 00:00:00 2001 From: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> Date: Tue, 29 Nov 2022 08:12:46 -0600 Subject: [PATCH 006/168] Prometheus: Remove raw query toggle (#59069) * remove the raw query option toggle from the prometheus query builder --- .../components/PromQueryBuilderContainer.tsx | 5 ++--- .../components/PromQueryEditorSelector.test.tsx | 9 +-------- .../components/PromQueryEditorSelector.tsx | 15 +-------------- .../querybuilder/components/QueryPreview.tsx | 6 ++---- 4 files changed, 6 insertions(+), 29 deletions(-) diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx index 37c38546420..05d84bc6fc4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx @@ -18,7 +18,6 @@ export interface Props { onChange: (update: PromQuery) => void; onRunQuery: () => void; data?: PanelData; - showRawQuery?: boolean; showExplain: boolean; } @@ -31,7 +30,7 @@ export interface State { * This component is here just to contain the translation logic between string query and the visual query builder model. */ export function PromQueryBuilderContainer(props: Props) { - const { query, onChange, onRunQuery, datasource, data, showRawQuery, showExplain } = props; + const { query, onChange, onRunQuery, datasource, data, showExplain } = props; const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr }); // Only rebuild visual query if expr changes from outside @@ -59,7 +58,7 @@ export function PromQueryBuilderContainer(props: Props) { data={data} showExplain={showExplain} /> - {showRawQuery && } + {} ); } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx index a90923a34cf..3e7ed2560f5 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.test.tsx @@ -95,14 +95,7 @@ describe('PromQueryEditorSelector', () => { }); }); - it('Can enable raw query', async () => { - renderWithMode(QueryEditorMode.Builder); - expect(screen.queryByLabelText('selector')).toBeInTheDocument(); - screen.getByLabelText('Raw query').click(); - expect(screen.queryByLabelText('selector')).not.toBeInTheDocument(); - }); - - it('Should show raw query by default', async () => { + it('Should show raw query', async () => { renderWithProps({ editorMode: QueryEditorMode.Builder, expr: 'my_metric', diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx index 1ae2582be86..ff7f9a88e32 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -11,7 +11,7 @@ import { promQueryModeller } from '../PromQueryModeller'; import { buildVisualQueryFromString } from '../parsing'; import { QueryEditorModeToggle } from '../shared/QueryEditorModeToggle'; import { QueryHeaderSwitch } from '../shared/QueryHeaderSwitch'; -import { promQueryEditorExplainKey, promQueryEditorRawQueryKey, useFlag } from '../shared/hooks/useFlag'; +import { promQueryEditorExplainKey, useFlag } from '../shared/hooks/useFlag'; import { QueryEditorMode } from '../shared/types'; import { changeEditorMode, getQueryWithDefaults } from '../state'; @@ -26,7 +26,6 @@ export const PromQueryEditorSelector = React.memo((props) => { const [parseModalOpen, setParseModalOpen] = useState(false); const [dataIsStale, setDataIsStale] = useState(false); const { flag: explain, setFlag: setExplain } = useFlag(promQueryEditorExplainKey); - const { flag: rawQuery, setFlag: setRawQuery } = useFlag(promQueryEditorRawQueryKey, true); const query = getQueryWithDefaults(props.query, app); // This should be filled in from the defaults by now. @@ -58,11 +57,6 @@ export const PromQueryEditorSelector = React.memo((props) => { setDataIsStale(false); }, [data]); - const onQueryPreviewChange = (event: SyntheticEvent) => { - const isEnabled = event.currentTarget.checked; - setRawQuery(isEnabled); - }; - const onChangeInternal = (query: PromQuery) => { setDataIsStale(true); onChange(query); @@ -102,13 +96,7 @@ export const PromQueryEditorSelector = React.memo((props) => { }} options={promQueryModeller.getQueryPatterns().map((x) => ({ label: x.name, value: x }))} /> - - {editorMode === QueryEditorMode.Builder && ( - <> - - - )} {app !== CoreApp.Explore && ( + ); + } + + return children; +} diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts new file mode 100644 index 00000000000..47263d4730e --- /dev/null +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table'; + +export interface Column { + /** + * ID of the column. Must be unique among all other columns + */ + id: IdType; + /** + * Custom render function for te cell + */ + cell?: (props: CellProps) => ReactNode; + /** + * Header name. if `undefined` the header will be empty. Useful for action columns. + */ + header?: string; + /** + * Column sort type. If `undefined` the column will not be sortable. + * */ + sortType?: DefaultSortTypes | SortByFn; + /** + * If `true` prevents the column from growing more than its content. + */ + disableGrow?: boolean; + /** + * If the provided function returns `false` the column will be hidden. + */ + visible?: (data: TableData[]) => boolean; +} diff --git a/public/app/features/correlations/components/Table/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts similarity index 84% rename from public/app/features/correlations/components/Table/utils.ts rename to packages/grafana-ui/src/components/InteractiveTable/utils.ts index 551157b87a7..7eb970008b1 100644 --- a/public/app/features/correlations/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -1,11 +1,9 @@ -import { uniqueId } from 'lodash'; import { Column as RTColumn } from 'react-table'; import { ExpanderCell } from './ExpanderCell'; +import { Column } from './types'; -import { Column } from '.'; - -export const EXPANDER_CELL_ID = '__expander'; +export const EXPANDER_CELL_ID = '__expander' as const; type InternalColumn = RTColumn & { visible?: (data: T[]) => boolean; @@ -24,11 +22,12 @@ export function getColumns(columns: Array>): Array ({ + id: column.id, + accessor: column.id, Header: column.header || (() => null), - accessor: column.id || uniqueId(), sortType: column.sortType || 'alphanumeric', disableSortBy: !Boolean(column.sortType), - width: column.shrink ? 0 : undefined, + width: column.disableGrow ? 0 : undefined, visible: column.visible, ...(column.cell && { Cell: column.cell }), })), diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index be1987ed037..8497191c78f 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -42,6 +42,7 @@ export { } from './DateTimePickers/DatePickerWithInput/DatePickerWithInput'; export { DateTimePicker } from './DateTimePickers/DateTimePicker/DateTimePicker'; export { List } from './List/List'; +export { InteractiveTable } from './InteractiveTable/InteractiveTable'; export { TagsInput } from './TagsInput/TagsInput'; export { Pagination } from './Pagination/Pagination'; export { Tag, type OnTagClick } from './Tags/Tag'; diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 79e87f3c5b6..042ee209e34 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -6,3 +6,4 @@ export * from './forms'; export * from './icon'; export * from './select'; export * from './size'; +export * from './interactiveTable'; diff --git a/packages/grafana-ui/src/types/interactiveTable.ts b/packages/grafana-ui/src/types/interactiveTable.ts new file mode 100644 index 00000000000..f999cb9aa5b --- /dev/null +++ b/packages/grafana-ui/src/types/interactiveTable.ts @@ -0,0 +1,2 @@ +export type { Column } from '../components/InteractiveTable/types'; +export type { CellProps, SortByFn } from 'react-table'; diff --git a/public/app/core/utils/types.ts b/public/app/core/utils/types.ts deleted file mode 100644 index 2bd160c6857..00000000000 --- a/public/app/core/utils/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type Truthy = T extends false | '' | 0 | null | undefined ? never : T; - -export const isTruthy = (value: T): value is Truthy => Boolean(value); diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index 2de67a10d60..d891c39bee5 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -1,4 +1,13 @@ -import { render, waitFor, screen, fireEvent, waitForElementToBeRemoved, within, Matcher } from '@testing-library/react'; +import { + render, + waitFor, + screen, + fireEvent, + waitForElementToBeRemoved, + within, + Matcher, + getByRole, +} from '@testing-library/react'; import { merge, uniqueId } from 'lodash'; import React from 'react'; import { DeepPartial } from 'react-hook-form'; @@ -411,7 +420,7 @@ describe('CorrelationsPage', () => { }); it('correctly sorts by source', async () => { - const sourceHeader = getHeaderByName('Source'); + const sourceHeader = getByRole(getHeaderByName('Source'), 'button'); fireEvent.click(sourceHeader); let cells = queryCellsByColumnName('Source'); cells.forEach((cell, i, allCells) => { diff --git a/public/app/features/correlations/CorrelationsPage.tsx b/public/app/features/correlations/CorrelationsPage.tsx index 730b32c9952..303d0827862 100644 --- a/public/app/features/correlations/CorrelationsPage.tsx +++ b/public/app/features/correlations/CorrelationsPage.tsx @@ -1,11 +1,22 @@ import { css } from '@emotion/css'; import { negate } from 'lodash'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { CellProps, SortByFn } from 'react-table'; import { GrafanaTheme2 } from '@grafana/data'; import { isFetchError, reportInteraction } from '@grafana/runtime'; -import { Badge, Button, DeleteButton, HorizontalGroup, LoadingPlaceholder, useStyles2, Alert } from '@grafana/ui'; +import { + Badge, + Button, + DeleteButton, + HorizontalGroup, + LoadingPlaceholder, + useStyles2, + Alert, + InteractiveTable, + type Column, + type CellProps, + type SortByFn, +} from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { useNavModel } from 'app/core/hooks/useNavModel'; @@ -14,7 +25,6 @@ import { AccessControlAction } from 'app/types'; import { AddCorrelationForm } from './Forms/AddCorrelationForm'; import { EditCorrelationForm } from './Forms/EditCorrelationForm'; import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA'; -import { Column, Table } from './components/Table'; import type { RemoveCorrelationParams } from './types'; import { CorrelationData, useCorrelations } from './useCorrelations'; @@ -97,8 +107,9 @@ export default function CorrelationsPage() { const columns = useMemo>>( () => [ { + id: 'info', cell: InfoCell, - shrink: true, + disableGrow: true, visible: (data) => data.some(isSourceReadOnly), }, { @@ -115,8 +126,9 @@ export default function CorrelationsPage() { }, { id: 'label', header: 'Label', sortType: 'alphanumeric' }, { + id: 'actions', cell: RowActions, - shrink: true, + disableGrow: true, visible: (data) => canWriteCorrelations && data.some(negate(isSourceReadOnly)), }, ], @@ -166,7 +178,7 @@ export default function CorrelationsPage() { {isAdding && setIsAdding(false)} onCreated={handleAdded} />} {data && data.length >= 1 && ( - ( Date: Tue, 29 Nov 2022 17:23:21 +0100 Subject: [PATCH 011/168] Add discord as a possible receiver in cloud rules (#59366) --- .../cloud-alertmanager-notifier-types.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index 4dcf2e44fdd..9f250b46db0 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -330,6 +330,26 @@ export const cloudNotifierTypes: NotifierDTO[] = [ httpConfigOption, ], }, + { + name: 'Discord', + description: 'Sends notifications to Discord', + type: 'discord', + info: '', + heading: 'Discord settings', + options: [ + option('title', 'Title', 'Templated title of the message', { + placeholder: '{{ template "discord.default.title" . }}', + }), + option( + 'message', + 'Message Content', + 'Mention a group using @ or a user using <@ID> when notifying in a channel', + { placeholder: '{{ template "discord.default.message" . }}' } + ), + option('webhook_url', 'Webhook URL', '', { placeholder: 'Discord webhook URL', required: true }), + httpConfigOption, + ], + }, ]; export const globalConfigOptions: NotificationChannelOption[] = [ From b2fdf46820d6df756c2129f80c9bfbc71a0da4a4 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 29 Nov 2022 17:21:07 +0000 Subject: [PATCH 012/168] Docs: Minor improvements to Preferences documentation (#59498) Small improvements to Preferences documentation --- .../organization-preferences/index.md | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index 91adba509de..e18fd09294f 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -111,17 +111,18 @@ Organization administrators can change the UI theme for all users in an organiza 1. Hover your cursor over the **Configuration** (gear) icon. 1. Click **Preferences**. -1. In the Preferences section, select the **UI theme**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ### Change team UI theme -Organization and team administrators can change the UI theme for all users in a team. +Organization and team administrators can change the UI theme for all users on a team. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team that you want to change the UI theme for and then navigate to the **Settings** tab. -1. In the Preferences section, select the **UI theme**. +1. Click the team for which you want to change the UI theme. +1. Click **Settings**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ### Change your personal UI theme @@ -129,7 +130,7 @@ Organization and team administrators can change the UI theme for all users in a You can change the UI theme for your user account. This setting overrides UI theme settings at higher levels. 1. On the left menu, hover your cursor over your avatar and then click **Preferences**. -1. In the Preferences section, select the **UI theme**. +1. In the **Preferences** section, select the **UI theme**. 1. Click **Save**. ## Change the Grafana default timezone @@ -153,11 +154,12 @@ Organization administrators can choose a default timezone for their organization ### Set team timezone -Organization administrators and team administrators can choose a default timezone for all users in a team. +Organization administrators and team administrators can choose a default timezone for all users on a team. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team you that you want to change the timezone for and then navigate to the **Settings** tab. +1. Click the team for which you want to change the timezone. +1. Click **Settings** 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. Refer to [[Time range controls]({{< relref "../../dashboards/manage-dashboards/#configure-dashboard-time-range-controls" >}}) for more information about Grafana time settings. 1. Click **Save**. @@ -207,7 +209,7 @@ default_home_dashboard_path = data/main-dashboard.json ### Set the home dashboard for your organization -Organization administrators can choose a home dashboard for their organization. +Organization administrators can choose a default home dashboard for their organization. 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. @@ -218,13 +220,14 @@ Organization administrators can choose a home dashboard for their organization. ### Set home dashboard for your team -Organization administrators and Team Admins can choose a home dashboard for a team. +Organization administrators and Team Admins can set a default home dashboard for all users on a team. 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team that you want to change the home dashboard for and then navigate to the **Settings** tab. +1. Click the team for which you want to change the home dashboard. +1. Click **Settings**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. @@ -250,17 +253,18 @@ Organization administrators can change the language for all users in an organiza 1. Hover your cursor over the **Configuration** (gear) icon. 1. Click **Preferences**. -1. In the Preferences section, select the **Language**. +1. In the **Preferences** section, select the **Language**. 1. Click **Save**. ### Change team language -Organization and team administrators can change the language for all users in a team. +Organization and team administrators can set a default language for all users on a team. 1. Hover your cursor over the **Configuration** (gear) icon in the side menu. 1. Click **Teams**. Grafana displays the team list. -1. Click on the team that you want to change the language for and then navigate to the **Settings** tab. -1. In the Preferences section, select the **Language**. +1. Click the team for which you want to change the language. +1. Click **Settings** +1. In the **Preferences** section, select the **Language**. 1. Click **Save**. ### Change your personal language @@ -268,5 +272,5 @@ Organization and team administrators can change the language for all users in a You can change the language for your user account. This setting overrides language settings at higher levels. 1. On the left menu, hover your cursor over your avatar and then click **Preferences**. -1. In the Preferences section, select the **language**. +1. In the **Preferences** section, select the **language**. 1. Click **Save**. From a77d95807ca0e85dd2745a2f5ba2795ddef44c49 Mon Sep 17 00:00:00 2001 From: Kevin Minehart Date: Tue, 29 Nov 2022 11:54:20 -0600 Subject: [PATCH 013/168] Store: skip flaky test in the store service (#59443) skip flaky test --- pkg/services/store/service_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 42683061453..c5d34a0674e 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -84,6 +84,7 @@ func TestListFiles(t *testing.T) { require.NoError(t, err) require.NotNil(t, file) + t.Skip("Skipping golden JSON frame test as it is flaky") testDsFrame, err := testdatasource.LoadCsvContent(bytes.NewReader(file.Contents), file.Name) require.NoError(t, err) experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata_js_libraries.golden", testDsFrame, true) From 1481ace52805eed0ca9b04a613131af4c37fa452 Mon Sep 17 00:00:00 2001 From: Alexander Weaver Date: Tue, 29 Nov 2022 13:18:47 -0600 Subject: [PATCH 014/168] Alerting: Fix swallowing of errors when attaching images to notifications (#59432) * Break out image logic and add logging * Attach alert log context to image attachment * Fix capitalization --- .../ngalert/notifier/channels/pushover.go | 64 ++++++++++--------- .../ngalert/notifier/channels/util.go | 4 +- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/pkg/services/ngalert/notifier/channels/pushover.go b/pkg/services/ngalert/notifier/channels/pushover.go index b795f7c6d18..293d537e783 100644 --- a/pkg/services/ngalert/notifier/channels/pushover.go +++ b/pkg/services/ngalert/notifier/channels/pushover.go @@ -248,6 +248,40 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al return nil, b, fmt.Errorf("failed write the message: %w", err) } + pn.writeImageParts(ctx, w, as...) + + var sound string + if status == model.AlertResolved { + sound = tmpl(pn.settings.okSound) + } else { + sound = tmpl(pn.settings.alertingSound) + } + if sound != "default" { + if err := w.WriteField("sound", sound); err != nil { + return nil, b, fmt.Errorf("failed to write the sound: %w", err) + } + } + + // Mark the message as HTML + if err := w.WriteField("html", "1"); err != nil { + return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) + } + if err := w.Close(); err != nil { + return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) + } + + if tmplErr != nil { + pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) + } + + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + } + + return headers, b, nil +} + +func (pn *PushoverNotifier) writeImageParts(ctx context.Context, w *multipart.Writer, as ...*types.Alert) { // Pushover supports at most one image attachment with a maximum size of pushoverMaxFileSize. // If the image is larger than pushoverMaxFileSize then return an error. _ = withStoredImages(ctx, pn.log, pn.images, func(index int, image ngmodels.Image) error { @@ -281,34 +315,4 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al return ErrImagesDone }, as...) - - var sound string - if status == model.AlertResolved { - sound = tmpl(pn.settings.okSound) - } else { - sound = tmpl(pn.settings.alertingSound) - } - if sound != "default" { - if err := w.WriteField("sound", sound); err != nil { - return nil, b, fmt.Errorf("failed to write the sound: %w", err) - } - } - - // Mark the message as HTML - if err := w.WriteField("html", "1"); err != nil { - return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) - } - if err := w.Close(); err != nil { - return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) - } - - if tmplErr != nil { - pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) - } - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - } - - return headers, b, nil } diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go index 409fa98901a..244418df1c9 100644 --- a/pkg/services/ngalert/notifier/channels/util.go +++ b/pkg/services/ngalert/notifier/channels/util.go @@ -82,7 +82,8 @@ func getImage(ctx context.Context, l log.Logger, imageStore ImageStore, alert ty // images have been found. func withStoredImages(ctx context.Context, l log.Logger, imageStore ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { for index, alert := range alerts { - img, err := getImage(ctx, l, imageStore, *alert) + logger := l.New("alert", alert.String()) + img, err := getImage(ctx, logger, imageStore, *alert) if err != nil { return err } else if img != nil { @@ -90,6 +91,7 @@ func withStoredImages(ctx context.Context, l log.Logger, imageStore ImageStore, if errors.Is(err, ErrImagesDone) { return nil } + logger.Error("Failed to attach image to notification", "error", err) return err } } From 038c97f31c4adf8c58263918d71c8e69a34745e4 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 29 Nov 2022 22:50:01 +0100 Subject: [PATCH 015/168] TestDatasource: Add scenario for generating trace data (#59299) --- pkg/tsdb/testdatasource/scenarios.go | 6 +++ .../datasource/testdata/QueryEditor.tsx | 12 ++++++ .../plugins/datasource/testdata/datasource.ts | 42 +++++++++++++++++++ .../app/plugins/datasource/testdata/types.ts | 1 + 4 files changed, 61 insertions(+) diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index adca0fd977e..07349c9bf84 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -43,6 +43,7 @@ const ( rawFrameQuery queryType = "raw_frame" csvFileQueryType queryType = "csv_file" csvContentQueryType queryType = "csv_content" + traceType queryType = "trace" ) type queryType string @@ -218,6 +219,11 @@ Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means handler: s.handleCsvContentScenario, }) + s.registerScenario(&Scenario{ + ID: string(traceType), + Name: "Trace", + }) + s.queryMux.HandleFunc("", s.handleFallbackScenario) } diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx index 084a6e0d125..b3c7d996b97 100644 --- a/public/app/plugins/datasource/testdata/QueryEditor.tsx +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -302,6 +302,18 @@ export const QueryEditor = ({ query, datasource, onChange, onRunQuery }: Props) onChange({ ...query, nodes: val })} query={query} /> )} {scenarioId === 'server_error_500' && } + {scenarioId === 'trace' && ( + + + + )} {description &&

{description}

} diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index fd9e42ea665..de176ee6de5 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -14,6 +14,7 @@ import { TimeRange, ScopedVars, toDataFrame, + MutableDataFrame, } from '@grafana/data'; import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { getSearchFilterScopedVar } from 'app/features/variables/utils'; @@ -70,6 +71,9 @@ export class TestDataDataSource extends DataSourceWithBackend { case 'flame_graph': streams.push(this.flameGraphQuery()); break; + case 'trace': + streams.push(this.trace(target, options)); + break; case 'raw_frame': streams.push(this.rawFrameQuery(target, options)); break; @@ -224,6 +228,44 @@ export class TestDataDataSource extends DataSourceWithBackend { return of({ data: [flameGraphData] }).pipe(delay(100)); } + trace(target: TestDataQuery, options: DataQueryRequest): Observable { + const frame = new MutableDataFrame({ + meta: { + preferredVisualisationType: 'trace', + }, + fields: [ + { name: 'traceID' }, + { name: 'spanID' }, + { name: 'parentSpanID' }, + { name: 'operationName' }, + { name: 'serviceName' }, + { name: 'serviceTags' }, + { name: 'startTime' }, + { name: 'duration' }, + { name: 'logs' }, + { name: 'references' }, + { name: 'tags' }, + ], + }); + const numberOfSpans = options.targets[0].spanCount || 10; + const spanIdPrefix = '75c665dfb68'; + const start = Date.now() - 1000 * 60 * 30; + + for (let i = 0; i < numberOfSpans; i++) { + frame.add({ + traceID: spanIdPrefix + '10000', + spanID: spanIdPrefix + (10000 + i), + parentSpanID: i === 0 ? '' : spanIdPrefix + 10000, + operationName: `Operation ${i}`, + serviceName: `Service ${i}`, + startTime: start + i * 100, + duration: 300, + }); + } + + return of({ data: [frame] }).pipe(delay(100)); + } + rawFrameQuery(target: TestDataQuery, options: DataQueryRequest): Observable { try { const data = JSON.parse(target.rawFrameContent ?? '[]').map((v: any) => { diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts index 82495788dcc..da8328ba32b 100644 --- a/public/app/plugins/datasource/testdata/types.ts +++ b/public/app/plugins/datasource/testdata/types.ts @@ -27,6 +27,7 @@ export interface TestDataQuery extends DataQuery { seriesCount?: number; usa?: USAQuery; errorType?: 'server_panic' | 'frontend_exception' | 'frontend_observable'; + spanCount?: number; } export interface NodesQuery { From 823a40bc8516062f38bcbd96b872f582a36b44e3 Mon Sep 17 00:00:00 2001 From: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Date: Tue, 29 Nov 2022 17:06:55 -0600 Subject: [PATCH 016/168] Docs: What's New content for v9.3 (#57991) * initial content for What's New 9.3 * Update docs/sources/whatsnew/whats-new-in-v9-3.md * Update docs/sources/whatsnew/whats-new-in-v9-3.md * makes prettier * docs: add conflict cli tool * remoed the conflict users tool in favor of not having it completely out yet * Adds note about pubdash annotations support in 9.3-beta notes * puts audit table photo back in right section * Docs: Add OAuth improvements to what's new in 9.3 (#58756) * Add OAuth improvements to what's new in v9.3 * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/whatsnew/whats-new-in-v9-3.md * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Mitch Seaman Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Docs: Add conflict cli tool (#58827) docs: add conflict cli tool * docs: add Terraform updates for What's New in Grafana 9.3 (#58858) * Terraform updates for Grafana 9.3 * Remove empty line * linting * Docs: Update OAuth improvements section of what's new in 9.3 (#59045) Update OAuth improvement docs for 9.3 * fix incorrect version number * Adds Alerting whats new entries * Add edge squad 9.3 whats new information * Docs: Update whats-new 9.3 with auth related news (#59093) * LDAP role mapping improvements * RBAC list token's permissions * Azure force_use_graph_api * Reorder * LDAP uniformize * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Ieva * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Ieva * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Ieva Co-authored-by: Ieva * adds new nav and new language to what's new * Add report zoom to What's New in 9.3 (#59345) * Add report zoom to What's New in 9.3 Still needs a link to docs, a double-check on the name of the feature toggle, and a screenshot. * Update whats-new-in-v9-3.md * Apply suggestions from code review * makes prettier Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Chris Moyer * add report zoom image * fix typo * fix link to upload images in template description * Copy edits * copy and format updates * Apply suggestions from code review * Update docs/sources/whatsnew/whats-new-in-v9-3.md * Update docs/sources/whatsnew/whats-new-in-v9-3.md * Update docs/sources/whatsnew/whats-new-in-v9-3.md * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/whatsnew/whats-new-in-v9-3.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Fix prettier * Update whats-new-in-v9-3.md * update nav wording * Screenshots for navigation and internazionalization * adds alerting images * fixes path to new nav and localization screenshots Co-authored-by: eleijonmarck Co-authored-by: Owen Smallwood Co-authored-by: Misi Co-authored-by: Mitch Seaman Co-authored-by: Ieva Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> Co-authored-by: nmarrs Co-authored-by: Gabriel MABILLE Co-authored-by: Mitchel Seaman Co-authored-by: Zsofia <97596715+zsofiakomaromigrafana@users.noreply.github.com> --- docs/sources/whatsnew/_index.md | 1 + docs/sources/whatsnew/whats-new-in-v9-3.md | 258 +++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 docs/sources/whatsnew/whats-new-in-v9-3.md diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 935ba017a0c..25a34aa1dd8 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -68,6 +68,7 @@ For a complete list of every change, with links to pull requests and related iss ## Grafana 9 +- [What's new in 9.3]({{< relref "whats-new-in-v9-3/" >}}) - [What's new in 9.2]({{< relref "whats-new-in-v9-2/" >}}) - [What's new in 9.1]({{< relref "whats-new-in-v9-1/" >}}) - [What's new in 9.0]({{< relref "whats-new-in-v9-0/" >}}) diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md new file mode 100644 index 00000000000..4ac3fc1be94 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -0,0 +1,258 @@ +--- +_build: + list: false +aliases: + - /docs/grafana/latest/guides/whats-new-in-v9-3/ +description: Feature and improvement highlights for Grafana v9.3 +keywords: + - grafana + - new + - documentation + - '9.3' + - release notes +title: What's new in Grafana v9.3 +weight: -33 +--- + +# What’s new in Grafana v9.3 + +Welcome to Grafana 9.3! Read on to learn about our navigation overhaul, support for four new languages, new panels and transformations, several often-requested auth improvements, usability improvements to Alerting, and more. For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/master/CHANGELOG.md). + +## New navigation + +Available in **beta** in all editions of Grafana + +Use Grafana’s redesigned navigation to get full visibility into the health of your systems, by quickly jumping between features as part of your incident response workflow. + +As Grafana has grown from a data visualization tool to an observability solution, we’ve added many new features along the way. This has resulted in pages that are visually inconsistent or hard to find. These updates to navigation give Grafana a new look and feel and make page layouts and navigation patterns more consistent. + +We’ve revamped the navigation menu and grouped related tools together, making it easier to find what you need. Pages in Grafana now leverage new layouts that include breadcrumbs and a sidebar, allowing you to quickly jump between pages. We’ve also introduced a header that appears on all pages in Grafana, making dashboard search accessible from any page. + +To try out Grafana’s new navigation, enable the `topnav` feature toggle. If you are a Cloud Advanced customer, open a ticket with our support team and we will enable it for you. + +**Note:** The Grafana and Grafana Cloud documentation has not yet been updated to reflect changes to the navigation - these changes will roll out when the new navigation becomes generally available. + +{{< figure src="/static/img/docs/navigation/navigation-9-3.png" max-width="750px" caption="New navigation for Grafana" >}} + +## View dashboards in Spanish, French, German, and Simplified Chinese + +Generally available in all editions of Grafana + +We have added four new languages to Grafana: Spanish, French, German, and Simplified Chinese. + +With millions of users across the globe, Grafana has a global footprint. In order to make it accessible to a wider audience, we have taken the first steps in localizing key workflows. You can now set Grafana’s language for the navigation, viewing dashboards, and some settings. This will cover the main activities a Viewer performs within Grafana. + +Read more about configuring the [default language for your organization]({{< relref "../administration/organization-preferences/" >}}) and [updating your profile]({{< relref "../administration/user-management/user-preferences/" >}}) in our documentation. + +{{< figure src="/static/img/docs/internationalization/internationalization-9-3.png" max-width="750px" caption="Grafana available in Spanish, French, German, and Simplified Chinese" >}} + +## Geomap panel + +Generally available in all editions of Grafana + +We have added a new alpha layer type in Geomap called photo layer. This layer enables you to render a photo at each data point. To learn more about the photo layer and the geomap panel, refer to [Photos layer]({{< relref "../panels-visualizations/visualizations/geomap/#photos-layer-alpha" >}}). + +{{< figure src="/static/img/docs/geomap-panel/geomap-photos-9-3-0.png" max-width="750px" caption="Geomap panel photos layer" >}} + +## Canvas panel + +Available in **beta** in all editions of Grafana + +Canvas is a new panel that combines the power of Grafana with the flexibility of custom elements. Canvas visualizations are extensible form-built panels that allow you to explicitly place elements within static and dynamic layouts. This empowers you to design custom visualizations and overlay data in ways that aren’t possible with standard Grafana panels, all within Grafana’s UI. If you’ve used popular UI and web design tools, then designing Canvas panels will feel very familiar. + +In Grafana v9.3, we have added icon value mapping support to the Canvas panel. This enables you to dynamically set which icon to display based on your data. To learn more about the Canvas panel, refer to [Canvas]({{< relref "../panels-visualizations/visualizations/canvas" >}}). + +{{< video-embed src="/static/img/docs/canvas-panel/canvas-icon-value-mapping-support-9-3-0.mp4" max-width="750px" caption="Canvas panel icon value mapping support" >}} + +## Public dashboards improvements + +We've made the following improvements to public dashboards. + +### Manage all of your public dashboards in one place + +Available in **experimental** in Grafana Open Source, Enterprise, and Cloud Advanced + +You can use Public Dashboards to make a given dashboard available to anyone on the internet without needing to sign in. In Grafana v9.3, we have introduced a new screen where you can manage all of your public dashboards. From here, you can view a list of all of the public dashboards in your Grafana instance, navigate to the underlying dashboard, see if it is enabled, link out to the public version of the dashboard, or update the public dashboard's configuration. You can see a public dashboard's configuration if you have view access to the dashboard itself, and you can edit its configuration if you have the Admin or Server Admin role or the "Public Dashboard writer" role if you are using RBAC in Grafana Enterprise or Cloud Advanced. + +To check out this new screen and configure your public dashboards, navigate to **Dashboards > Public Dashboards**. + +### Choose to display annotations in public dashboards + +Available in **experimental** in Grafana Open Source, Enterprise, and Cloud Advanced + +Annotations are now supported in public dashboards, with the exception of query annotations. They are turned off by default, but can be turned on in your public dashboard settings. + +Note that because Public Dashboards is an experimental feature, you need to enable it in Grafana using the `publicDashboards` [feature toggle]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}), or open a support ticket requesting public dashboards if you are a Cloud Advanced customer. + +To learn more about public dashboards, refer to [Public dashboards]({{< relref "../dashboards/dashboard-public/" >}}). + +## New transformation: Partition by values + +Available in **experimental** in all editions of Grafana + +This new transformation can help eliminate the need for multiple queries to the same datasource with different WHERE clauses when graphing multiple series. + +Consider a metrics SQL table with the following data: + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | US | 1327 | +| 2022-10-20 01:00:00 | EU | 912 | + +Prior to v9.3, if you wanted to plot a red trendline for US and a blue one for EU in the same TimeSeries panel, you would likely have to split this into two queries: + +``` + SELECT Time, Value FROM metrics WHERE Time > ‘2022-10-20’ AND Region=’US’ + SELECT Time, Value FROM metrics WHERE Time > ‘2022-10-20’ AND Region=’EU’ +``` + +This approach also requires you to know ahead of time which regions exist in the metrics table. + +With the partition by values transformer, you can issue a single query and split the results by unique (enum) values from one or more columns (fields) of your choosing. In this case, Region. + +``` + SELECT Time, Region, Value FROM metrics WHERE Time > ‘2022-10-20’ +``` + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | US | 1520 | +| 2022-10-20 01:00:00 | US | 1327 | + +| Time | Region | Value | +| ------------------- | ------ | ----- | +| 2022-10-20 12:00:00 | EU | 2936 | +| 2022-10-20 01:00:00 | EU | 912 | + +## Reporting: Zoom in and out to fit your data better into a PDF + +Generally available in Grafana Enterprise, Cloud Pro, and Cloud Advanced. + +Because dashboards appear on a screen and reports are PDFs, it can be challenging to render data just the way you want to. Sometimes the report doesn't show enough columns in a table, or the titles appear too small. Now you can adjust the scale of your report to zoom in and make each text field and panel larger or zoom out to show more data. + +The zoom feature is located in the **Format Report** section of your reporting configuration. To learn more about reporting, refer to [Create and manage reports]({{< relref "../dashboards/create-reports/">}}). + +{{< figure src="/static/img/docs/enterprise/reports/report-zoom.png" max-width="750px" caption="Report zoom feature with PDF documents at three different zoom levels" >}} + +## Users and access + +We've made the following improvements to users and access. + +### OAuth: token handling improvements + +Generally available in all editions of Grafana + +As part of our efforts to improve the security of Grafana, we are introducing a long-awaited feature that enhances Grafana's OAuth 2.0 compatibility. When a user logs in using an OAuth provider, Grafana verifies on each request that the user's access token has not expired. Grafana uses the refresh token provided (if any exists) when an access token expires to obtain a new access token. + +Because this feature introduces a breaking change, it is behind the `accessTokenExpirationCheck` feature toggle and is disabled by default. Enabling this functionality without configuring refresh tokens for the specific OAuth provider will sign users out after their access token has expired, and they would need to sign in again every time. + +Complete documentation on how to configure obtaining a refresh token can be found on the [authentication configuration page]({{< relref "../setup-grafana/configure-security/configure-authentication/" >}}), in the instructions for your Oauth identity provider. + +### Resolve user conflicts in Grafana's CLI + +In the older versions of Grafana, usernames were case-sensitive. This created conflicts, where a user might sign in using two different methods (like SAML and OAuth) and have two accounts created, like `elastigirl@incredibles.com` and `ElastiGirl@incredibles.com`. Users in this situation might think they have lost their preferences and permissions. If this has occurred in your Grafana instance, you can use a new Grafana CLI command to resolve user identity conflicts between users within Grafana. + +> Note: If you use Grafana Cloud or you run Grafana with MySQL as your database, you will not experience any user identity conflicts and you do not need to use this tool. + +```bash +# lists all the conflicting users +$ grafana-cli user-manager conflicts list + +# creates a conflict patch file to edit +$ grafana-cli user-manager conflicts generate-file + +# reads edited conflict patch file for validation +$ grafana-cli user-manager conflicts validate-file + +# ingests the conflict users file. Can be executed once per file and will change the state of the database. +$ grafana-cli user-manager conflicts ingest-file +``` + +### LDAP: Role mapping improvements + +Generally available in all editions of Grafana + +If you use an LDAP directory to authenticate to Grafana but prefer to assign organizations and roles in the Grafana UI +or via API, you can now skip user organization role synchronization with your LDAP +directory. + +Use the `skip_org_role_sync` [LDAP authentication configuration option]({{< relref +"../setup-grafana/configure-security/configure-authentication/ldap/#disable-org-role-synchronization" >}}) +when configuring LDAP authentication to prevent the synchronization between your LDAP groups and organization roles +and make user roles editable manually. + +### Azure AD OAuth2: New option to always fetch groups from the Graph API + +Generally available in all editions of Grafana + +If you use Azure AD OAuth2 authentication and use `SecurityEnabled` groups that you don't want Azure to embed in the +authentication token, you can configure Grafana to use Microsoft's Graph API instead. + +Use the [`force_use_graph_api` configuration option]({{< relref +"../setup-grafana/configure-security/configure-authentication/azuread/#force-fetching-groups-from-microsoft-graph-api" >}}) +when configuring Azure AD authentication to force Grafana to fetch groups using Graph API. + +### RBAC: List token's permissions + +Generally available in Grafana Enterprise and Cloud Advanced + +We added a new endpoint to help users diagnose permissions-related issues with user and token authorization. +[This endpoint]({{< relref "../developers/http_api/access_control/#list-your-permissions" >}}) allows users to get the +full list of RBAC permissions associated with their token. + +For more details, refer to [Debug the permissions of a service account token]({{< relref +"../administration/service-accounts/#debug-the-permissions-of-a-service-account-token" >}}). + +### RBAC with Terraform: Extended support for provisioning permissions + +Generally available in Grafana Enterprise and Cloud Advanced + +All Grafana users can now use the latest release of [Terraform's Grafana provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs) (version 1.31.1+) to provision [user and team access to service accounts]({{< relref "../administration/service-accounts/#manage-users-and-teams-permissions-for-a-service-account-in-grafana" >}}). + +This allows full management of service accounts through Terraform - from creating a service account and allowing users to access it to assigning roles to the service account and generating service account tokens. + +Grafana Enterprise and Cloud Pro and Advanced users can now provision [access to data sources]({{< relref "../administration/data-source-management/#data-source-permissions" >}}) for Grafana's `Viewer`, `Editor`, and `Admin` basic roles, as well as assign `Edit` permission. + +We have also added [documentation on provisioning RBAC roles and role assignments]({{< relref "../administration/roles-and-permissions/access-control/rbac-terraform-provisioning/" >}}) to guide our Grafana Enterprise and Cloud Pro and Advanced users through this process. + +Finally, we have fixed several access control related bugs to ensure a smoother provisioning experience. + +## Alerting + +All of these new alerting features are generally available in all editions of Grafana. + +### Email templating + +We've improved the design and functionality of email templates to make template creation much easier and more customizable. The email template framework utilizes MJML to define and compile the final email HTML output. Sprig functions in the email templates provide more customizable template functions. + +{{< figure src="/static/img/docs/alerting/alert-templates-whats-new-v9.3.png" max-width="750px" caption="Email template redesign" >}} + +### Support for Webex Teams + +You can now use Cisco Webex Teams as a contact point, to send alerts to a Webex Teams channel. + +### Edit alert rules created using the provisioning API + +Edit API-provisioned alert rules from the Grafana UI. To make a provisioned alert editable, add the `x-disable-provenance` header to the following requests when creating or editing your alert rules in the API: + +POST /api/v1/provisioning/alert-rules + +PUT /api/v1/provisioning/alert-rules/{UID} + +### Support values in notification templates + +Add alert values to notification templates, so that you can create a single template that prints the annotations, labels, and values for your alerts in a format of your choice. + +### View notification errors + +When an alert fails to fire, see when something is wrong with your contact point(s) and the reason for the error. The Receivers API contains information on the error, including a time stamp, duration of the attempt, and the error. You can also view the errors for each contact point in the UI. + +{{< figure src="/static/img/docs/alerting/alert-view-notification-errors-whats-new-v9.3.png" max-width="750px" caption="Alert notification errors" >}} + +### Redesign of the expressions pipeline + +We've redesigned the expressions pipeline editor to combine the expressions editor and the preview into a single view. + +{{< figure src="/static/img/docs/alerting/alert-expression-pipeline-whats-new-v9.3.png" max-width="750px" caption="Expression pipeline redesign" >}} From ce0bdb2cd9bd2f1e3fd7a1830531aba9babe80df Mon Sep 17 00:00:00 2001 From: sam boyer Date: Tue, 29 Nov 2022 19:01:45 -0500 Subject: [PATCH 017/168] kindsys: Fix loading on windows (#59519) --- pkg/kindsys/load.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/pkg/kindsys/load.go b/pkg/kindsys/load.go index 29448f9052b..81bf9073c9f 100644 --- a/pkg/kindsys/load.go +++ b/pkg/kindsys/load.go @@ -8,11 +8,8 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/errors" - "github.com/grafana/thema" - tload "github.com/grafana/thema/load" - - "github.com/grafana/grafana" "github.com/grafana/grafana/pkg/cuectx" + "github.com/grafana/thema" ) // CoreStructuredDeclParentPath is the path, relative to the repository root, where @@ -49,20 +46,14 @@ func loadpFrameworkOnce() { }) } -var prefix = filepath.Join("/pkg", "kindsys") - func doLoadFrameworkCUE(ctx *cue.Context) (cue.Value, error) { - var v cue.Value - var err error - - bi, err := tload.InstancesWithThema(grafana.CueSchemaFS, prefix) + v, err := cuectx.BuildGrafanaInstance(ctx, filepath.Join("pkg", "kindsys"), "kindsys", nil) if err != nil { return v, err } - v = ctx.BuildInstance(bi) if err = v.Validate(cue.Concrete(false), cue.All()); err != nil { - return cue.Value{}, fmt.Errorf("coremodel framework loaded cue.Value has err: %w", err) + return cue.Value{}, fmt.Errorf("kindsys framework loaded cue.Value has err: %w", err) } return v, nil From 122f0d947ec53fc7a1a54d4942a55ae080a57b2e Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 29 Nov 2022 20:46:39 -0600 Subject: [PATCH 018/168] BarChart: when horiz, allow rotation and skipping of y tick labels (#59354) --- .betterer.results | 14 +- .../barchart-label-rotation-skipping.json | 320 ++++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 7 + .../barchart/__snapshots__/utils.test.ts.snap | 30 +- public/app/plugins/panel/barchart/bars.ts | 71 ++-- public/app/plugins/panel/barchart/module.tsx | 10 +- public/app/plugins/panel/barchart/utils.ts | 44 ++- 7 files changed, 429 insertions(+), 67 deletions(-) create mode 100644 devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json diff --git a/.betterer.results b/.betterer.results index 116b3c7b307..8844760fb39 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6929,11 +6929,12 @@ exports[`better eslint`] = { "public/app/plugins/panel/barchart/bars.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Unexpected any. Specify a different type.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"] ], "public/app/plugins/panel/barchart/module.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -6944,7 +6945,8 @@ exports[`better eslint`] = { ], "public/app/plugins/panel/barchart/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/panel/candlestick/CandlestickPanel.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json b/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json new file mode 100644 index 00000000000..43d7655053e --- /dev/null +++ b/devenv/dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json @@ -0,0 +1,320 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 530, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decmbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 11, + "x": 0, + "y": 0 + }, + "id": 2, + "maxDataPoints": 30, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "vertical", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelMaxLength": 6, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 100 + }, + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"label\",\n \"type\": \"string\",\n \"typeInfo\": {\n \"frame\": \"string\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 600000\n }\n },\n {\n \"name\": \"A-series\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {},\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n \"acquisition\",\n \"extension\",\n \"conductor\",\n \"authorise\",\n \"architect\",\n \"illusion\",\n \"congress\",\n \"highlight\",\n \"partnership\",\n \"understanding\",\n \"disagreement\",\n \"personality\",\n \"commerce\",\n \"systematic\",\n \"hesitate\",\n \"business\",\n \"manufacture\",\n \"incredible\",\n \"constitutional\",\n \"prevalence\",\n \"professor\",\n \"entitlement\",\n \"cooperation\",\n \"sickness\",\n \"contrast\",\n \"reference\",\n \"audience\",\n \"discount\",\n \"apparatus\",\n \"disturbance\",\n \"automatic\",\n \"refrigerator\",\n \"elaborate\",\n \"sympathetic\",\n \"integration\",\n \"president\"\n ],\n [\n 306.78931659492116,\n 200.00696051101917,\n 164.90889283973593,\n 518.9385023737021,\n 999.9040675564702,\n 613.9689830172349,\n 773.2337077340269,\n 317.47395634701644,\n 748.3318338316539,\n 606.8039493787173,\n 426.27771317792866,\n 376.47735643253924,\n 66.30635081800493,\n 401.70654338415505,\n 108.86259550477234,\n 182.40284186231278,\n 867.7047958572101,\n 959.3957783599242,\n 396.7606089549935,\n 455.9625595614323,\n 685.4792456298062,\n 368.6567303946707,\n 157.06596562976327,\n 59.54120602048763,\n 406.72723615743973,\n 440.18247585615575,\n 516.0267558264891,\n 258.76006051667315,\n 952.966531725171,\n 554.8746357628739,\n 86.7279280805682,\n 781.2422516386563,\n 754.2723802427706,\n 435.0305712850233,\n 384.43181614983,\n 459.04164596738127\n ]\n ]\n }\n }\n]", + "refId": "A", + "scenarioId": "raw_frame" + } + ], + "title": "Panel Title", + "type": "barchart" + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMin": 0, + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 7, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 14, + "w": 13, + "x": 11, + "y": 0 + }, + "id": 5, + "options": { + "barRadius": 0, + "barWidth": 1, + "groupWidth": 0.82, + "legend": { + "calcs": [ + "max" + ], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "text": {}, + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "csvContent": "Name,Stat1,Stat2\nStockholm, 10, 15\nNew York, 19, -5\nLondon, 10, 1\nLong value, 15,10", + "refId": "A", + "scenarioId": "csv_content" + } + ], + "title": "Auto sizing & auto show values", + "type": "barchart" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decmbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 18, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 3, + "maxDataPoints": 20, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelMaxLength": 5, + "xTickLabelRotation": 45, + "xTickLabelSpacing": 100 + }, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "A" + } + ], + "title": "Panel Title", + "type": "barchart" + } + ], + "schemaVersion": 37, + "style": "dark", + "tags": [ + "gdev", + "panel-tests", + "barchart", + "graph-ng" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "BarChart - Label Rotation & Skipping", + "uid": "xCmMwXdVz", + "version": 20, + "weekStart": "" + } diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index fa47dc594bf..f5787a61e6f 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -107,6 +107,13 @@ local dashboard = grafana.dashboard; id: 0, } }, + dashboard.new('barchart-label-rotation-skipping', import '../dev-dashboards/panel-barchart/barchart-label-rotation-skipping.json') + + resource.addMetadata('folder', 'dev-dashboards') + + { + spec+: { + id: 0, + } + }, dashboard.new('barchart-thresholds-mappings', import '../dev-dashboards/panel-barchart/barchart-thresholds-mappings.json') + resource.addMetadata('folder', 'dev-dashboards') + { diff --git a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap index 9fe14c366ec..48b5f0f3b87 100644 --- a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap +++ b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap @@ -13,7 +13,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -40,7 +40,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -167,7 +167,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -194,7 +194,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -348,7 +348,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 3`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": 0, "scale": "m/s", "show": true, "side": 3, @@ -475,7 +475,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -502,7 +502,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -629,7 +629,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -656,7 +656,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -783,7 +783,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -810,7 +810,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -937,7 +937,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -964,7 +964,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, @@ -1091,7 +1091,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": -0, + "rotate": 0, "scale": "x", "show": true, "side": 3, @@ -1118,7 +1118,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = ` "width": 1, }, "labelGap": 0, - "rotate": undefined, + "rotate": -0, "scale": "m/s", "show": true, "side": 2, diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index 08bc77b456d..41bccb9040b 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -51,6 +51,7 @@ export interface BarsOptions { getColor?: (seriesIdx: number, valueIdx: number, value: any) => string | null; fillOpacity?: number; formatValue: (seriesIdx: number, value: any) => string; + formatShortValue: (seriesIdx: number, value: any) => string; timeZone?: TimeZone; text?: VizTextDisplayOptions; onHover?: (seriesIdx: number, valueIdx: number) => void; @@ -116,7 +117,17 @@ function calculateFontSizeWithMetrics( * @internal */ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { - const { xOri, xDir: dir, rawValue, getColor, formatValue, fillOpacity = 1, showValue, xSpacing = 0 } = opts; + const { + xOri, + xDir: dir, + rawValue, + getColor, + formatValue, + formatShortValue, + fillOpacity = 1, + showValue, + xSpacing = 0, + } = opts; const isXHorizontal = xOri === ScaleOrientation.Horizontal; const hasAutoValueSize = !Boolean(opts.text?.valueSize); const isStacked = opts.stacking !== StackingMode.None; @@ -131,35 +142,34 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { let qt: Quadtree; let hRect: Rect | null; - const xSplits: Axis.Splits = (u: uPlot) => { - const dim = isXHorizontal ? u.bbox.width : u.bbox.height; - const _dir = dir * (isXHorizontal ? 1 : -1); + // for distr: 2 scales, the splits array should contain indices into data[0] rather than values + const xSplits: Axis.Splits | undefined = (u) => Array.from(u.data[0].map((v, i) => i)); - let dataLen = u.data[0].length; - let lastIdx = dataLen - 1; + const hFilter: Axis.Filter | undefined = + xSpacing === 0 + ? undefined + : (u, splits) => { + // hSpacing? + const dim = u.bbox.width; + const _dir = dir * (isXHorizontal ? 1 : -1); - let skipMod = 0; + let dataLen = splits.length; + let lastIdx = dataLen - 1; - if (xSpacing !== 0) { - let cssDim = dim / devicePixelRatio; - let maxTicks = Math.abs(Math.floor(cssDim / xSpacing)); + let skipMod = 0; - skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks); - } + let cssDim = dim / uPlot.pxRatio; + let maxTicks = Math.abs(Math.floor(cssDim / xSpacing)); - let splits: number[] = []; + skipMod = dataLen < maxTicks ? 0 : Math.ceil(dataLen / maxTicks); - // for distr: 2 scales, the splits array should contain indices into data[0] rather than values - u.data[0].forEach((v, i) => { - let shouldSkip = skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0; + let splits2 = splits.map((v, i) => { + let shouldSkip = skipMod !== 0 && (xSpacing > 0 ? i : lastIdx - i) % skipMod > 0; + return shouldSkip ? null : v; + }); - if (!shouldSkip) { - splits.push(i); - } - }); - - return _dir === 1 ? splits : splits.reverse(); - }; + return _dir === 1 ? splits2 : splits2.reverse(); + }; // the splits passed into here are data[0] values looked up by the indices returned from splits() const xValues: Axis.Values = (u, splits, axisIdx, foundSpace, foundIncr) => { @@ -182,7 +192,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { return vals; } - return splits.map((v) => formatValue(0, v)); + return splits.map((v) => (isXHorizontal ? formatShortValue(0, v) : formatValue(0, v))); }; // this expands the distr: 2 scale so that the indicies of each data[0] land at the proper justified positions @@ -434,8 +444,8 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { if (seriesIdx === 1) { hRect = null; - let cx = u.cursor.left! * devicePixelRatio; - let cy = u.cursor.top! * devicePixelRatio; + let cx = u.cursor.left! * uPlot.pxRatio; + let cy = u.cursor.top! * uPlot.pxRatio; qt.get(cx, cy, 1, 1, (o) => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { @@ -471,10 +481,10 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { } return { - left: isHovered ? (hRect!.x + widthReduce) / devicePixelRatio : -10, - top: isHovered ? hRect!.y / devicePixelRatio : -10, - width: isHovered ? (hRect!.w - widthReduce) / devicePixelRatio : 0, - height: isHovered ? (hRect!.h - heightReduce) / devicePixelRatio : 0, + left: isHovered ? (hRect!.x + widthReduce) / uPlot.pxRatio : -10, + top: isHovered ? hRect!.y / uPlot.pxRatio : -10, + width: isHovered ? (hRect!.w - widthReduce) / uPlot.pxRatio : 0, + height: isHovered ? (hRect!.h - heightReduce) / uPlot.pxRatio : 0, }; }, }, @@ -634,6 +644,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { xRange, xValues, xSplits, + hFilter, barsBuilder, diff --git a/public/app/plugins/panel/barchart/module.tsx b/public/app/plugins/panel/barchart/module.tsx index 1a4cb2a7cc5..77cdc1495a2 100644 --- a/public/app/plugins/panel/barchart/module.tsx +++ b/public/app/plugins/panel/barchart/module.tsx @@ -138,7 +138,7 @@ export const plugin = new PanelPlugin(BarChartPa }) .addSliderInput({ path: 'xTickLabelRotation', - name: 'Rotate bar labels', + name: 'Rotate X tick labels', defaultValue: defaultPanelOptions.xTickLabelRotation, settings: { min: -90, @@ -147,18 +147,16 @@ export const plugin = new PanelPlugin(BarChartPa marks: { '-90': '-90°', '-45': '-45°', 0: '0°', 45: '45°', 90: '90°' }, included: false, }, - showIf: (opts) => { - return opts.orientation === VizOrientation.Auto || opts.orientation === VizOrientation.Vertical; - }, }) .addNumberInput({ path: 'xTickLabelMaxLength', - name: 'Bar label max length', - description: 'Bar labels will be truncated to the length provided', + name: 'X tick label max length', + description: 'X labels will be truncated to the length provided', settings: { placeholder: 'None', min: 0, }, + showIf: (opts) => opts.xTickLabelRotation !== 0, }) .addCustomEditor({ id: 'xTickLabelSpacing', diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index 0e4d3b8b529..64bd4da4fca 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -81,15 +81,18 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ timeZone, }) => { const builder = new UPlotConfigBuilder(); - const defaultValueFormatter = (seriesIdx: number, value: any) => { - return shortenValue(formattedValueToString(frame.fields[seriesIdx].display!(value)), xTickLabelMaxLength); + + const formatValue = (seriesIdx: number, value: any) => { + return formattedValueToString(frame.fields[seriesIdx].display!(value)); + }; + + const formatShortValue = (seriesIdx: number, value: any) => { + return shortenValue(formatValue(seriesIdx, value), xTickLabelMaxLength); }; // bar orientation -> x scale orientation & direction const vizOrientation = getBarCharScaleOrientation(orientation); - const formatValue = defaultValueFormatter; - // Use bar width when only one field if (frame.fields.length === 2) { groupWidth = barWidth; @@ -107,6 +110,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ getColor, fillOpacity, formatValue, + formatShortValue, timeZone, text, showValue, @@ -126,8 +130,13 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ builder.setTooltipInterpolator(config.interpolateTooltip); - if (vizOrientation.xOri === ScaleOrientation.Horizontal && xTickLabelRotation !== 0) { - builder.setPadding(getRotationPadding(frame, xTickLabelRotation, xTickLabelMaxLength)); + if (xTickLabelRotation !== 0) { + // these are the amount of space we already have available between plot edge and first label + // TODO: removing these hardcoded value requires reading back uplot instance props + let lftSpace = 50; + let btmSpace = vizOrientation.xOri === ScaleOrientation.Horizontal ? 14 : 5; + + builder.setPadding(getRotationPadding(frame, xTickLabelRotation, xTickLabelMaxLength, lftSpace, btmSpace)); } builder.setPrepData(config.prepData); @@ -155,12 +164,13 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ placement: xFieldAxisPlacement, label: frame.fields[0].config.custom?.axisLabel, splits: config.xSplits, + filter: vizOrientation.xOri === 0 ? config.hFilter : undefined, values: config.xValues, timeZone, grid: { show: false }, ticks: { show: false }, gap: 15, - tickLabelRotation: xTickLabelRotation * -1, + tickLabelRotation: vizOrientation.xOri === 0 ? xTickLabelRotation * -1 : 0, theme, show: xFieldAxisShow, }); @@ -272,6 +282,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ size: customConfig.axisWidth, placement, formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), + filter: vizOrientation.yOri === 0 ? config.hFilter : undefined, + tickLabelRotation: vizOrientation.xOri === 1 ? xTickLabelRotation * -1 : 0, theme, grid: { show: customConfig.axisGridShow }, }); @@ -293,7 +305,13 @@ function shortenValue(value: string, length: number) { } } -function getRotationPadding(frame: DataFrame, rotateLabel: number, valueMaxLength: number): Padding { +function getRotationPadding( + frame: DataFrame, + rotateLabel: number, + valueMaxLength: number, + lftSpace = 0, + btmSpace = 0 +): Padding { const values = frame.fields[0].values; const fontSize = UPLOT_AXIS_FONT_SIZE; const displayProcessor = frame.fields[0].display ?? ((v) => v); @@ -325,9 +343,15 @@ function getRotationPadding(frame: DataFrame, rotateLabel: number, valueMaxLengt : 0; // Add padding to the bottom to avoid clipping the rotated labels. - const paddingBottom = Math.sin(((rotateLabel >= 0 ? rotateLabel : rotateLabel * -1) * Math.PI) / 180) * maxLength; + const paddingBottom = + Math.sin(((rotateLabel >= 0 ? rotateLabel : rotateLabel * -1) * Math.PI) / 180) * maxLength - btmSpace; - return [Math.round(UPLOT_AXIS_FONT_SIZE * uPlot.pxRatio), paddingRight, paddingBottom, paddingLeft]; + return [ + Math.round(UPLOT_AXIS_FONT_SIZE * uPlot.pxRatio), + paddingRight, + paddingBottom, + Math.max(0, paddingLeft - lftSpace), + ]; } /** @internal */ From 6805c951e94c626401770ed468c6afb462093c0d Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 29 Nov 2022 23:50:59 -0600 Subject: [PATCH 019/168] Plugins: add option to proxy ds connections through a secure socks proxy (#59254) * Plugins: add feature to proxy data source connections --- conf/defaults.ini | 11 ++ conf/sample.ini | 10 + .../src/types/featureToggles.gen.ts | 1 + .../http_client_provider.go | 11 +- .../httpclientprovider/secure_socks_proxy.go | 72 +++++++ .../secure_socks_proxy_test.go | 177 ++++++++++++++++++ pkg/services/featuremgmt/registry.go | 4 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/setting/setting.go | 9 + pkg/setting/setting_secure_socks_proxy.go | 44 +++++ 10 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go create mode 100644 pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go create mode 100644 pkg/setting/setting_secure_socks_proxy.go diff --git a/conf/defaults.ini b/conf/defaults.ini index 49c4582a280..115cbc20a73 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1359,3 +1359,14 @@ index_update_interval = 10s # Move a specific app plugin page (referenced by its `path` field) to a specific navigation section # Format: =
[navigation.app_standalone_pages] + + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +enabled = false +root_ca_cert = +client_key = +client_cert = +server_name = +# The address of the socks5 proxy datasources should connect to +proxy_address = \ No newline at end of file diff --git a/conf/sample.ini b/conf/sample.ini index 7c27b34819b..d421d04c10a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1285,3 +1285,13 @@ [navigation.app_standalone_pages] # The following will move the page with the path "/a/my-app-id/starred-content" from `my-app-id` to the `starred` section # /a/my-app-id/starred-content = starred + +#################################### Secure Socks5 Datasource Proxy ##################################### +[secure_socks_datasource_proxy] +; enabled = false +; root_ca_cert = +; client_key = +; client_cert = +; server_name = +# The address of the socks5 proxy datasources should connect to +; proxy_address = \ No newline at end of file diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f0f688c5b0e..e57ca1881ea 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -82,5 +82,6 @@ export interface FeatureToggles { nestedFolders?: boolean; accessTokenExpirationCheck?: boolean; elasticsearchBackendMigration?: boolean; + secureSocksDatasourceProxy?: boolean; authnService?: boolean; } diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 81d45aabaa9..054f045471d 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) @@ -50,10 +51,18 @@ func New(cfg *setting.Cfg, validator models.PluginRequestValidator, tracer traci return } datasourceLabelName, err := metricutil.SanitizeLabelName(datasourceName) - if err != nil { return } + + if cfg.IsFeatureToggleEnabled(featuremgmt.FlagSecureSocksDatasourceProxy) && + cfg.SecureSocksDSProxy.Enabled && secureSocksProxyEnabledOnDS(opts) { + err = newSecureSocksProxy(&cfg.SecureSocksDSProxy, transport) + if err != nil { + logger.Error("Failed to enable secure socks proxy", "error", err.Error(), "datasource", datasourceName) + } + } + newConntrackRoundTripper(datasourceLabelName, transport) }, }) diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go new file mode 100644 index 00000000000..65e4da5bdff --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy.go @@ -0,0 +1,72 @@ +package httpclientprovider + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "os" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana/pkg/setting" + "golang.org/x/net/proxy" +) + +// newSecureSocksProxy takes a http.DefaultTransport and wraps it in a socks5 proxy with TLS +func newSecureSocksProxy(cfg *setting.SecureSocksDSProxySettings, transport *http.Transport) error { + certPool := x509.NewCertPool() + for _, rootCAFile := range strings.Split(cfg.RootCA, " ") { + // nolint:gosec + // The gosec G304 warning can be ignored because `rootCAFile` comes from config ini. + pem, err := os.ReadFile(rootCAFile) + if err != nil { + return err + } + if !certPool.AppendCertsFromPEM(pem) { + return errors.New("failed to append CA certificate " + rootCAFile) + } + } + + cert, err := tls.LoadX509KeyPair(cfg.ClientCert, cfg.ClientKey) + if err != nil { + return err + } + + tlsDialer := &tls.Dialer{ + Config: &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: cfg.ServerName, + RootCAs: certPool, + }, + } + dialSocksProxy, err := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, tlsDialer) + if err != nil { + return err + } + + contextDialer, ok := dialSocksProxy.(proxy.ContextDialer) + if !ok { + return errors.New("unable to cast socks proxy dialer to context proxy dialer") + } + + transport.DialContext = contextDialer.DialContext + + return nil +} + +// secureSocksProxyEnabledOnDS checks the datasource json data to see if the secure socks proxy is enabled on it +func secureSocksProxyEnabledOnDS(opts sdkhttpclient.Options) bool { + jsonData := backend.JSONDataFromHTTPClientOptions(opts) + res, enabled := jsonData["enableSecureSocksProxy"] + if !enabled { + return false + } + + if val, ok := res.(bool); ok { + return val + } + + return false +} diff --git a/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go new file mode 100644 index 00000000000..857141c5d03 --- /dev/null +++ b/pkg/infra/httpclient/httpclientprovider/secure_socks_proxy_test.go @@ -0,0 +1,177 @@ +package httpclientprovider + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSecureSocksProxy(t *testing.T) { + proxyAddress := "localhost:3000" + serverName := "localhost" + tempDir := t.TempDir() + + // create empty file for testing invalid configs + tempEmptyFile := filepath.Join(tempDir, "emptyfile.txt") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + _, err := os.Create(tempEmptyFile) + require.NoError(t, err) + + // generate test rootCA + ca := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + rootCACert := filepath.Join(tempDir, "ca.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + caCertFile, err := os.Create(rootCACert) + require.NoError(t, err) + err = pem.Encode(caCertFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }) + require.NoError(t, err) + + // generate test client cert & key + cert := &x509.Certificate{ + SerialNumber: big.NewInt(2019), + Subject: pkix.Name{ + Organization: []string{"Grafana Labs"}, + CommonName: "Grafana", + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + SubjectKeyId: []byte{1, 2, 3, 4, 6}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + require.NoError(t, err) + certBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &certPrivKey.PublicKey, caPrivKey) + require.NoError(t, err) + clientCert := filepath.Join(tempDir, "client.cert") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + certFile, err := os.Create(clientCert) + require.NoError(t, err) + err = pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + require.NoError(t, err) + clientKey := filepath.Join(tempDir, "client.key") + // nolint:gosec + // The gosec G304 warning can be ignored because all values come from the test + keyFile, err := os.Create(clientKey) + require.NoError(t, err) + err = pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey), + }) + require.NoError(t, err) + + settings := &setting.SecureSocksDSProxySettings{ + ClientCert: clientCert, + ClientKey: clientKey, + RootCA: rootCACert, + ServerName: serverName, + ProxyAddress: proxyAddress, + } + + t.Run("New socks proxy should be properly configured when all settings are valid", func(t *testing.T) { + require.NoError(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Client cert must be valid", func(t *testing.T) { + settings.ClientCert = tempEmptyFile + t.Cleanup(func() { + settings.ClientCert = clientCert + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Client key must be valid", func(t *testing.T) { + settings.ClientKey = tempEmptyFile + t.Cleanup(func() { + settings.ClientKey = clientKey + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) + + t.Run("Root CA must be valid", func(t *testing.T) { + settings.RootCA = tempEmptyFile + t.Cleanup(func() { + settings.RootCA = rootCACert + }) + require.Error(t, newSecureSocksProxy(settings, &http.Transport{})) + }) +} + +func TestSecureSocksProxyEnabledOnDS(t *testing.T) { + t.Run("Secure socks proxy should only be enabled when the json data contains enableSecureSocksProxy=true", func(t *testing.T) { + tests := []struct { + instanceSettings *backend.AppInstanceSettings + enabled bool + }{ + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{}"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": \"nonbool\" }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": false }"), + }, + enabled: false, + }, + { + instanceSettings: &backend.AppInstanceSettings{ + JSONData: []byte("{ \"enableSecureSocksProxy\": true }"), + }, + enabled: true, + }, + } + + for _, tt := range tests { + opts, err := tt.instanceSettings.HTTPClientOptions() + assert.NoError(t, err) + + assert.Equal(t, tt.enabled, secureSocksProxyEnabledOnDS(opts)) + } + }) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7176751ccd5..6c27029ce24 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -367,6 +367,10 @@ var ( Description: "Use Elasticsearch as backend data source", State: FeatureStateAlpha, }, + { + Name: "secureSocksDatasourceProxy", + Description: "Enable secure socks tunneling for supported core datasources", + }, { Name: "authnService", Description: "Use new auth service to perform authentication", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 3d95cf8a59e..74d7f9a1f42 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -271,6 +271,10 @@ const ( // Use Elasticsearch as backend data source FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" + // FlagSecureSocksDatasourceProxy + // Enable secure socks tunneling for supported core datasources + FlagSecureSocksDatasourceProxy = "secureSocksDatasourceProxy" + // FlagAuthnService // Use new auth service to perform authentication FlagAuthnService = "authnService" diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4754d5c028e..70372fcd9f3 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -475,6 +475,8 @@ type Cfg struct { Search SearchSettings + SecureSocksDSProxy SecureSocksDSProxySettings + // Access Control RBACEnabled bool RBACPermissionCache bool @@ -1080,6 +1082,13 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { cfg.Storage = readStorageSettings(iniFile) cfg.Search = readSearchSettings(iniFile) + cfg.SecureSocksDSProxy, err = readSecureSocksDSProxySettings(iniFile) + if err != nil { + // if the proxy is misconfigured, disable it rather than crashing + cfg.SecureSocksDSProxy.Enabled = false + cfg.Logger.Error("secure_socks_datasource_proxy unable to start up", "err", err.Error()) + } + if VerifyEmailEnabled && !cfg.Smtp.Enabled { cfg.Logger.Warn("require_email_validation is enabled but smtp is disabled") } diff --git a/pkg/setting/setting_secure_socks_proxy.go b/pkg/setting/setting_secure_socks_proxy.go new file mode 100644 index 00000000000..83fbb156a9e --- /dev/null +++ b/pkg/setting/setting_secure_socks_proxy.go @@ -0,0 +1,44 @@ +package setting + +import ( + "errors" + + "gopkg.in/ini.v1" +) + +type SecureSocksDSProxySettings struct { + Enabled bool + ClientCert string + ClientKey string + RootCA string + ProxyAddress string + ServerName string +} + +func readSecureSocksDSProxySettings(iniFile *ini.File) (SecureSocksDSProxySettings, error) { + s := SecureSocksDSProxySettings{} + secureSocksProxySection := iniFile.Section("secure_socks_datasource_proxy") + s.Enabled = secureSocksProxySection.Key("enabled").MustBool(false) + s.ClientCert = secureSocksProxySection.Key("client_cert").MustString("") + s.ClientKey = secureSocksProxySection.Key("client_key").MustString("") + s.RootCA = secureSocksProxySection.Key("root_ca_cert").MustString("") + s.ProxyAddress = secureSocksProxySection.Key("proxy_address").MustString("") + s.ServerName = secureSocksProxySection.Key("server_name").MustString("") + + if !s.Enabled { + return s, nil + } + + // all fields must be specified to use the proxy + if s.RootCA == "" { + return s, errors.New("rootCA required") + } else if s.ClientCert == "" || s.ClientKey == "" { + return s, errors.New("client key pair required") + } else if s.ServerName == "" { + return s, errors.New("server name required") + } else if s.ProxyAddress == "" { + return s, errors.New("proxy address required") + } + + return s, nil +} From 312dbc979e3126bbdd491beb68c976910905f979 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Wed, 30 Nov 2022 10:36:11 +0200 Subject: [PATCH 020/168] Changelog: Updated changelog for 9.2.7 (#59525) --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ed5a9e9dd..28e6d7bda36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,24 @@ The following functions and classes related to logs are deprecated in the `grafa - **Toolkit:** Deprecate `component:create` command. [#56086](https://github.com/grafana/grafana/pull/56086), [@academo](https://github.com/academo) - **Toolkit:** Remove changelog command. [#56073](https://github.com/grafana/grafana/pull/56073), [@gitstart](https://github.com/gitstart) + + +# 9.2.7 (2022-11-29) + +### Bug fixes + +- **Access Control:** Clear user's permission cache after resource creation. [#59318](https://github.com/grafana/grafana/pull/59318), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Azure Monitor:** Fix empty/errored responses for Logs variables. [#59240](https://github.com/grafana/grafana/pull/59240), [@andresmgot](https://github.com/andresmgot) +- **Azure Monitor:** Fix resource picker selection for subresources. [#56392](https://github.com/grafana/grafana/pull/56392), [@andresmgot](https://github.com/andresmgot) +- **Navigation:** Fix crash when Help is disabled. [#58919](https://github.com/grafana/grafana/pull/58919), [@lpskdl](https://github.com/lpskdl) +- **PostgreSQL:** Fix missing CA field from configuration. [#59280](https://github.com/grafana/grafana/pull/59280), [@oscarkilhed](https://github.com/oscarkilhed) +- **SQL Datasources:** Fix annotation migration. [#59438](https://github.com/grafana/grafana/pull/59438), [@zoltanbedi](https://github.com/zoltanbedi) +- **SQL:** Fix code editor for SQL datasources. [#58116](https://github.com/grafana/grafana/pull/58116), [@zoltanbedi](https://github.com/zoltanbedi) +- **SSE:** Make sure to forward headers, user and cookies/OAuth token. [#58897](https://github.com/grafana/grafana/pull/58897), [@kylebrandt](https://github.com/kylebrandt) +- **TimeseriesPanel:** Preserve string fields for data link interpolation. [#58424](https://github.com/grafana/grafana/pull/58424), [@mdvictor](https://github.com/mdvictor) + + + # 9.2.6 (2022-11-22) From c72322874d3a4c4e236dde2a8ccb5591005fdf68 Mon Sep 17 00:00:00 2001 From: mikkancso Date: Wed, 30 Nov 2022 09:41:01 +0100 Subject: [PATCH 021/168] Connections: Update "Your connections/Data sources" page (#58589) * navtree.go: update Data sources title and subtitle * DataSourceList: move add button to header * DataSourcesList: add buttons to items The action buttons are added inside `` so that they end up at the right end of the card, as it was designed. The "Build a Dashboard" button's functionality is not defined yet. * DataSourcesListHeader: add sort picker * fix css * tests: look for the updated "Add new data source" text * tests: use an async test method to verify component updates are wrapped in an act() * update e2e selector for add data source button * fix DataSourceList{,Page} tests * add comment for en dash character * simplify sorting * add link to Build a Dashboard button * fix test * test build a dashboard and explore buttons * test sorting data source elements * DataSourceAddButton: hide button when user has no permission * PageActionBar: remove unneeded '?' * DataSourcesList: hide explore button if user has no permission * DataSourcesListPage.test: make setup prop explicit * DataSourcesList: use theme.spacing * datasources: assure explore url includes appSubUrl * fix tests and add test case for missing permissions Co-authored-by: Levente Balogh --- .../src/selectors/pages.ts | 2 +- .../src/themes/GlobalStyles/page.ts | 3 +- pkg/services/navtree/navtreeimpl/navtree.go | 4 +- .../PageActionBar/PageActionBar.tsx | 24 ++++- .../features/connections/Connections.test.tsx | 21 +++- .../connections/pages/DataSourcesListPage.tsx | 3 +- .../components/DataSourceAddButton.tsx | 20 ++++ .../components/DataSourcesList.test.tsx | 40 +++++-- .../components/DataSourcesList.tsx | 27 ++++- .../components/DataSourcesListHeader.tsx | 48 ++++----- .../pages/DataSourcesListPage.test.tsx | 102 +++++++++++++----- .../datasources/pages/DataSourcesListPage.tsx | 3 +- .../app/features/datasources/state/hooks.ts | 8 +- .../features/datasources/state/reducers.ts | 9 ++ .../features/datasources/state/selectors.ts | 7 +- public/app/features/datasources/utils.ts | 9 ++ public/app/types/datasources.ts | 1 + 17 files changed, 250 insertions(+), 81 deletions(-) create mode 100644 public/app/features/datasources/components/DataSourceAddButton.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index a6d0faae018..8fa94cb8d9c 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -31,7 +31,7 @@ export const Pages = { url: '/datasources/new', /** @deprecated Use dataSourcePluginsV2 */ dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`, - dataSourcePluginsV2: (pluginName: string) => `Add data source ${pluginName}`, + dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`, }, ConfirmModal: { delete: 'Confirm Modal Danger Button', diff --git a/packages/grafana-ui/src/themes/GlobalStyles/page.ts b/packages/grafana-ui/src/themes/GlobalStyles/page.ts index 033d0beac4a..1c301bc188d 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/page.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/page.ts @@ -90,7 +90,8 @@ export function getPageStyles(theme: GrafanaTheme2) { align-items: flex-start; > a, - > button { + > button, + > div:nth-child(2) { margin-left: ${theme.spacing(2)}; } } diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 16f0a5e231d..602080c53d9 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -554,8 +554,8 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree // Datasources Children: []*navtree.NavLink{{ Id: "connections-your-connections-datasources", - Text: "Datasources", - SubTitle: "Manage your existing datasource connections", + Text: "Data sources", + SubTitle: "View and manage your connected data source connections", Url: baseUrl + "/your-connections/datasources", }}, }) diff --git a/public/app/core/components/PageActionBar/PageActionBar.tsx b/public/app/core/components/PageActionBar/PageActionBar.tsx index eac3fcc8f3c..f85f0bab465 100644 --- a/public/app/core/components/PageActionBar/PageActionBar.tsx +++ b/public/app/core/components/PageActionBar/PageActionBar.tsx @@ -1,18 +1,33 @@ import React, { PureComponent } from 'react'; +import { SelectableValue } from '@grafana/data'; import { LinkButton, FilterInput } from '@grafana/ui'; +import { SortPicker } from '../Select/SortPicker'; + export interface Props { searchQuery: string; setSearchQuery: (value: string) => void; linkButton?: { href: string; title: string; disabled?: boolean }; target?: string; placeholder?: string; + sortPicker?: { + onChange: (sortValue: SelectableValue) => void; + value?: string; + getSortOptions?: () => Promise; + }; } export default class PageActionBar extends PureComponent { render() { - const { searchQuery, linkButton, setSearchQuery, target, placeholder = 'Search by name or type' } = this.props; + const { + searchQuery, + linkButton, + setSearchQuery, + target, + placeholder = 'Search by name or type', + sortPicker, + } = this.props; const linkProps: typeof LinkButton.defaultProps = { href: linkButton?.href, disabled: linkButton?.disabled }; if (target) { @@ -24,6 +39,13 @@ export default class PageActionBar extends PureComponent {
+ {sortPicker && ( + + )} {linkButton && {linkButton.title}} ); diff --git a/public/app/features/connections/Connections.test.tsx b/public/app/features/connections/Connections.test.tsx index d6c8404848c..043e09321d1 100644 --- a/public/app/features/connections/Connections.test.tsx +++ b/public/app/features/connections/Connections.test.tsx @@ -4,6 +4,7 @@ import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { locationService } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; import { getMockDataSources } from 'app/features/datasources/__mocks__'; import * as api from 'app/features/datasources/api'; import { configureStore } from 'app/store/configureStore'; @@ -14,6 +15,7 @@ import Connections from './Connections'; import { navIndex } from './__mocks__/store.navIndex.mock'; import { ROUTE_BASE_ID, ROUTES } from './constants'; +jest.mock('app/core/services/context_srv'); jest.mock('app/features/datasources/api'); const renderPage = ( @@ -36,6 +38,7 @@ describe('Connections', () => { beforeEach(() => { (api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources); + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); }); test('shows the "Data sources" page by default', async () => { @@ -43,7 +46,8 @@ describe('Connections', () => { expect(await screen.findByText('Datasources')).toBeVisible(); expect(await screen.findByText('Manage your existing datasource connections')).toBeVisible(); - expect(await screen.findByRole('link', { name: /add data source/i })).toBeVisible(); + expect(await screen.findByText('Sort by A–Z')).toBeVisible(); + expect(await screen.findByRole('link', { name: /add new data source/i })).toBeVisible(); expect(await screen.findByText(mockDatasources[0].name)).toBeVisible(); }); @@ -57,7 +61,15 @@ describe('Connections', () => { expect(screen.queryByText('Manage your existing datasource connections')).not.toBeInTheDocument(); }); - test('renders the "Connect data" page using a plugin in case it is a standalone plugin page', async () => { + test('renders the core "Connect data" page in case there is no standalone plugin page override for it', async () => { + renderPage(ROUTES.ConnectData); + + // We expect to see no results and "Data sources" as a header (we only have data sources in OSS Grafana at this point) + expect(await screen.findByText('Data sources')).toBeVisible(); + expect(await screen.findByText('No results matching your query were found.')).toBeVisible(); + }); + + test('does not render anything for the "Connect data" page in case it is displayed by a standalone plugin page', async () => { // We are overriding the navIndex to have the "Connect data" page registered by a plugin const standalonePluginPage = { id: 'standalone-plugin-page-/connections/connect-data', @@ -83,7 +95,10 @@ describe('Connections', () => { renderPage(ROUTES.ConnectData, store); - // We expect not to see the same text as if it was rendered by core. + // We expect not to see the text that would be rendered by the core "Connect data" page + // (Instead we expect to see the default route "Datasources") + expect(await screen.findByText('Datasources')).toBeVisible(); + expect(await screen.findByText('Manage your existing datasource connections')).toBeVisible(); expect(screen.queryByText('No results matching your query were found.')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/connections/pages/DataSourcesListPage.tsx b/public/app/features/connections/pages/DataSourcesListPage.tsx index 518cc4c39db..17612363ba4 100644 --- a/public/app/features/connections/pages/DataSourcesListPage.tsx +++ b/public/app/features/connections/pages/DataSourcesListPage.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; import { Page } from 'app/core/components/Page/Page'; +import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton'; import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList'; export function DataSourcesListPage() { return ( - + diff --git a/public/app/features/datasources/components/DataSourceAddButton.tsx b/public/app/features/datasources/components/DataSourceAddButton.tsx new file mode 100644 index 00000000000..ca57d7c4165 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceAddButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { LinkButton } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; + +import { useDataSourcesRoutes } from '../state'; + +export function DataSourceAddButton() { + const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); + const dataSourcesRoutes = useDataSourcesRoutes(); + + return ( + canCreateDataSource && ( + + Add new data source + + ) + ); +} diff --git a/public/app/features/datasources/components/DataSourcesList.test.tsx b/public/app/features/datasources/components/DataSourcesList.test.tsx index 45991e8bc1c..bb6cf117858 100644 --- a/public/app/features/datasources/components/DataSourcesList.test.tsx +++ b/public/app/features/datasources/components/DataSourcesList.test.tsx @@ -2,12 +2,15 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; +import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { getMockDataSources } from '../__mocks__'; import { DataSourcesListView } from './DataSourcesList'; +jest.mock('app/core/services/context_srv'); + const setup = () => { const store = configureStore(); @@ -24,17 +27,38 @@ const setup = () => { }; describe('', () => { - it('should render list of datasources', () => { - setup(); - - expect(screen.getAllByRole('listitem')).toHaveLength(3); - expect(screen.getAllByRole('heading')).toHaveLength(3); + beforeEach(() => { + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); }); - it('should render all elements in the list item', () => { + it('should render action bar', async () => { setup(); - expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument(); + expect(await screen.findByRole('combobox', { name: 'Sort' })).toBeInTheDocument(); + }); + + it('should render list of datasources', async () => { + setup(); + + expect(await screen.findAllByRole('listitem')).toHaveLength(3); + expect(await screen.findAllByRole('heading')).toHaveLength(3); + expect(await screen.findAllByRole('link', { name: 'Build a Dashboard' })).toHaveLength(3); + expect(await screen.findAllByRole('link', { name: 'Explore' })).toHaveLength(3); + }); + + it('should render all elements in the list item', async () => { + setup(); + + expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'dataSource-0' })).toBeInTheDocument(); + }); + + it('should not render Explore button if user has no permissions', async () => { + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(false); + setup(); + + expect(await screen.findAllByRole('link', { name: 'Build a Dashboard' })).toHaveLength(3); + expect(screen.queryAllByRole('link', { name: 'Explore' })).toHaveLength(0); }); }); diff --git a/public/app/features/datasources/components/DataSourcesList.tsx b/public/app/features/datasources/components/DataSourcesList.tsx index 06343ce5d69..39cc16c1f4d 100644 --- a/public/app/features/datasources/components/DataSourcesList.tsx +++ b/public/app/features/datasources/components/DataSourcesList.tsx @@ -1,14 +1,15 @@ import { css } from '@emotion/css'; import React from 'react'; -import { DataSourceSettings } from '@grafana/data'; -import { Card, Tag, useStyles2 } from '@grafana/ui'; +import { DataSourceSettings, GrafanaTheme2 } from '@grafana/data'; +import { LinkButton, Card, Tag, useStyles2 } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { contextSrv } from 'app/core/core'; import { StoreState, AccessControlAction, useSelector } from 'app/types'; import { getDataSources, getDataSourcesCount, useDataSourcesRoutes, useLoadDataSources } from '../state'; +import { constructDataSourceExploreUrl } from '../utils'; import { DataSourcesListHeader } from './DataSourcesListHeader'; @@ -40,6 +41,7 @@ export type ViewProps = { export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) { const styles = useStyles2(getStyles); const dataSourcesRoutes = useDataSourcesRoutes(); + const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); if (isLoading) { return ; @@ -83,6 +85,22 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, dataSource.isDefault && , ]} + + + Build a Dashboard + + {canExploreDataSources && ( + + Explore + + )} + ); @@ -92,7 +110,7 @@ export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, ); } -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { list: css({ listStyle: 'none', @@ -102,5 +120,8 @@ const getStyles = () => { logo: css({ objectFit: 'contain', }), + button: css({ + marginLeft: theme.spacing(2), + }), }; }; diff --git a/public/app/features/datasources/components/DataSourcesListHeader.tsx b/public/app/features/datasources/components/DataSourcesListHeader.tsx index 8db867bb245..4961a22e172 100644 --- a/public/app/features/datasources/components/DataSourcesListHeader.tsx +++ b/public/app/features/datasources/components/DataSourcesListHeader.tsx @@ -1,42 +1,40 @@ import React, { useCallback } from 'react'; -import { AnyAction } from 'redux'; +import { SelectableValue } from '@grafana/data'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; -import { contextSrv } from 'app/core/core'; -import { AccessControlAction, StoreState, useSelector, useDispatch } from 'app/types'; +import { StoreState, useSelector, useDispatch } from 'app/types'; -import { getDataSourcesSearchQuery, setDataSourcesSearchQuery, useDataSourcesRoutes } from '../state'; +import { getDataSourcesSearchQuery, getDataSourcesSort, setDataSourcesSearchQuery, setIsSortAscending } from '../state'; + +const ascendingSortValue = 'alpha-asc'; +const descendingSortValue = 'alpha-desc'; + +const sortOptions = [ + // We use this unicode 'en dash' character (U+2013), because it looks nicer + // than simple dash in this context. This is also used in the response of + // the `sorting` endpoint, which is used in the search dashboard page. + { label: 'Sort by A–Z', value: ascendingSortValue }, + { label: 'Sort by Z–A', value: descendingSortValue }, +]; export function DataSourcesListHeader() { const dispatch = useDispatch(); const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]); const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources)); - const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); - return ( - + const setSort = useCallback( + (sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)), + [dispatch] ); -} + const isSortAscending = useSelector(({ dataSources }: StoreState) => getDataSourcesSort(dataSources)); -export type ViewProps = { - searchQuery: string; - setSearchQuery: (q: string) => AnyAction; - canCreateDataSource: boolean; -}; - -export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) { - const dataSourcesRoutes = useDataSourcesRoutes(); - const linkButton = { - href: dataSourcesRoutes.New, - title: 'Add data source', - disabled: !canCreateDataSource, + const sortPicker = { + onChange: setSort, + value: isSortAscending ? ascendingSortValue : descendingSortValue, + getSortOptions: () => Promise.resolve(sortOptions), }; return ( - + ); } diff --git a/public/app/features/datasources/pages/DataSourcesListPage.test.tsx b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx index 95eaaf78f39..ced9e960885 100644 --- a/public/app/features/datasources/pages/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx @@ -2,28 +2,30 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; -import { DataSourceSettings, LayoutModes } from '@grafana/data'; +import { LayoutModes } from '@grafana/data'; +import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; -import { DataSourcesState } from 'app/types'; import { navIndex, getMockDataSources } from '../__mocks__'; +import { getDataSources } from '../api'; import { initialState } from '../state'; import { DataSourcesListPage } from './DataSourcesListPage'; -jest.mock('app/core/services/backend_srv', () => ({ - ...jest.requireActual('app/core/services/backend_srv'), - getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }), +jest.mock('app/core/services/context_srv'); +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getDataSources: jest.fn().mockResolvedValue([]), })); -const setup = (stateOverride?: Partial) => { +const getDataSourcesMock = getDataSources as jest.Mock; + +const setup = (options: { isSortAscending: boolean }) => { const store = configureStore({ dataSources: { ...initialState, - dataSources: [] as DataSourceSettings[], layoutMode: LayoutModes.Grid, - hasFetched: false, - ...stateOverride, + isSortAscending: options.isSortAscending, }, navIndex, }); @@ -36,28 +38,70 @@ const setup = (stateOverride?: Partial) => { }; describe('Render', () => { - it('should render component', () => { - setup(); - - expect(screen.getByRole('heading', { name: 'Configuration' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Documentation' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Support' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Community' })).toBeInTheDocument(); + beforeEach(() => { + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(true); }); - it('should render action bar and datasources', () => { - setup({ - dataSources: getMockDataSources(5), - dataSourcesCount: 5, - hasFetched: true, - }); + it('should render component', async () => { + setup({ isSortAscending: true }); - expect(screen.getByRole('link', { name: 'Add data source' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'dataSource-1' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'dataSource-3' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'dataSource-4' })).toBeInTheDocument(); - expect(screen.getAllByRole('img')).toHaveLength(5); + expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Documentation' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Support' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Community' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Add new data source' })).toBeInTheDocument(); + }); + + it('should not render "Add new data source" button if user has no permissions', async () => { + (contextSrv.hasPermission as jest.Mock) = jest.fn().mockReturnValue(false); + setup({ isSortAscending: true }); + + expect(await screen.findByRole('heading', { name: 'Configuration' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Documentation' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Support' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Community' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Add new data source' })).toBeNull(); + }); + + it('should render action bar and datasources', async () => { + getDataSourcesMock.mockResolvedValue(getMockDataSources(5)); + + setup({ isSortAscending: true }); + + expect(await screen.findByPlaceholderText('Search by name or type')).toBeInTheDocument(); + expect(await screen.findByRole('combobox', { name: 'Sort' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'dataSource-1' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'dataSource-2' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'dataSource-3' })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'dataSource-4' })).toBeInTheDocument(); + expect(await screen.findAllByRole('img')).toHaveLength(5); + }); + + describe('should render elements in sort order', () => { + it('ascending', async () => { + getDataSourcesMock.mockResolvedValue(getMockDataSources(5)); + setup({ isSortAscending: true }); + + expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + const dataSourceItems = await screen.findAllByRole('heading'); + + expect(dataSourceItems).toHaveLength(6); + expect(dataSourceItems[0]).toHaveTextContent('Configuration'); + expect(dataSourceItems[1]).toHaveTextContent('dataSource-0'); + expect(dataSourceItems[2]).toHaveTextContent('dataSource-1'); + }); + it('descending', async () => { + getDataSourcesMock.mockResolvedValue(getMockDataSources(5)); + setup({ isSortAscending: false }); + + expect(await screen.findByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); + const dataSourceItems = await screen.findAllByRole('heading'); + + expect(dataSourceItems).toHaveLength(6); + expect(dataSourceItems[0]).toHaveTextContent('Configuration'); + expect(dataSourceItems[1]).toHaveTextContent('dataSource-4'); + expect(dataSourceItems[2]).toHaveTextContent('dataSource-3'); + }); }); }); diff --git a/public/app/features/datasources/pages/DataSourcesListPage.tsx b/public/app/features/datasources/pages/DataSourcesListPage.tsx index 2315b036367..e8ddc5f4587 100644 --- a/public/app/features/datasources/pages/DataSourcesListPage.tsx +++ b/public/app/features/datasources/pages/DataSourcesListPage.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { Page } from 'app/core/components/Page/Page'; +import { DataSourceAddButton } from '../components/DataSourceAddButton'; import { DataSourcesList } from '../components/DataSourcesList'; export function DataSourcesListPage() { return ( - + diff --git a/public/app/features/datasources/state/hooks.ts b/public/app/features/datasources/state/hooks.ts index cc6bc00c054..70bf7acfaa4 100644 --- a/public/app/features/datasources/state/hooks.ts +++ b/public/app/features/datasources/state/hooks.ts @@ -1,6 +1,6 @@ import { useContext, useEffect } from 'react'; -import { DataSourcePluginMeta, DataSourceSettings, NavModelItem, urlUtil } from '@grafana/data'; +import { DataSourcePluginMeta, DataSourceSettings, NavModelItem } from '@grafana/data'; import { cleanUpAction } from 'app/core/actions/cleanUp'; import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; @@ -9,6 +9,7 @@ import { AccessControlAction, useDispatch, useSelector } from 'app/types'; import { ShowConfirmModalEvent } from 'app/types/events'; import { DataSourceRights } from '../types'; +import { constructDataSourceExploreUrl } from '../utils'; import { initDataSourceSettings, @@ -108,10 +109,7 @@ export const useDataSource = (uid: string) => { export const useDataSourceExploreUrl = (uid: string) => { const dataSource = useDataSource(uid); - const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' }); - const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState }); - - return exploreUrl; + return constructDataSourceExploreUrl(dataSource); }; export const useDataSourceMeta = (pluginType: string): DataSourcePluginMeta => { diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 1153c40962a..c0ee343d378 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -19,6 +19,7 @@ export const initialState: DataSourcesState = { hasFetched: false, isLoadingDataSources: false, dataSourceMeta: {} as DataSourcePluginMeta, + isSortAscending: true, }; export const dataSourceLoaded = createAction('dataSources/dataSourceLoaded'); @@ -33,6 +34,7 @@ export const setDataSourcesLayoutMode = createAction('dataSources/se export const setDataSourceTypeSearchQuery = createAction('dataSources/setDataSourceTypeSearchQuery'); export const setDataSourceName = createAction('dataSources/setDataSourceName'); export const setIsDefault = createAction('dataSources/setIsDefault'); +export const setIsSortAscending = createAction('dataSources/setIsSortAscending'); // Redux Toolkit uses ImmerJs as part of their solution to ensure that state objects are not mutated. // ImmerJs has an autoFreeze option that freezes objects from change which means this reducer can't be migrated to createSlice @@ -93,6 +95,13 @@ export const dataSourcesReducer = (state: DataSourcesState = initialState, actio }; } + if (setIsSortAscending.match(action)) { + return { + ...state, + isSortAscending: action.payload, + }; + } + return state; }; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 36181f4e7c8..3585de24db0 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -4,9 +4,13 @@ import { DataSourcesState } from 'app/types/datasources'; export const getDataSources = (state: DataSourcesState) => { const regex = new RegExp(state.searchQuery, 'i'); - return state.dataSources.filter((dataSource: DataSourceSettings) => { + const filteredDataSources = state.dataSources.filter((dataSource: DataSourceSettings) => { return regex.test(dataSource.name) || regex.test(dataSource.database) || regex.test(dataSource.type); }); + + return filteredDataSources.sort((a, b) => + state.isSortAscending ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name) + ); }; export const getFilteredDataSourcePlugins = (state: DataSourcesState) => { @@ -35,3 +39,4 @@ export const getDataSourceMeta = (state: DataSourcesState, type: string): DataSo export const getDataSourcesSearchQuery = (state: DataSourcesState) => state.searchQuery; export const getDataSourcesLayoutMode = (state: DataSourcesState) => state.layoutMode; export const getDataSourcesCount = (state: DataSourcesState) => state.dataSourcesCount; +export const getDataSourcesSort = (state: DataSourcesState) => state.isSortAscending; diff --git a/public/app/features/datasources/utils.ts b/public/app/features/datasources/utils.ts index 48dcb500fe2..7da4a2d41fc 100644 --- a/public/app/features/datasources/utils.ts +++ b/public/app/features/datasources/utils.ts @@ -1,3 +1,5 @@ +import { DataSourceJsonData, DataSourceSettings, urlUtil, locationUtil } from '@grafana/data'; + interface ItemWithName { name: string; } @@ -45,3 +47,10 @@ function incrementLastDigit(digit: number) { function getNewName(name: string) { return name.slice(0, name.length - 1); } + +export const constructDataSourceExploreUrl = (dataSource: DataSourceSettings) => { + const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' }); + const exploreUrl = urlUtil.renderUrl(locationUtil.assureBaseUrl('/explore'), { left: exploreState }); + + return exploreUrl; +}; diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 3b05d3a07cb..207bd18995c 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -14,6 +14,7 @@ export interface DataSourcesState { isLoadingDataSources: boolean; plugins: DataSourcePluginMeta[]; categories: DataSourcePluginCategory[]; + isSortAscending: boolean; } export interface TestingStatus { From 207b2993b2e6f5c5ae6c42ad84ca740020f8d9a0 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 30 Nov 2022 09:41:28 +0100 Subject: [PATCH 022/168] Plugins Catalog: Only allow admins to access plugins catalog (#57101) * feat(plugins-catalog): only allow admins to access plugins catalog routes * add backend check * fix(plugins-catalog): update route role access to include server admins Co-authored-by: Will Browne --- pkg/api/api.go | 8 ++++---- pkg/middleware/auth.go | 10 ++++++++++ public/app/features/plugins/admin/routes.tsx | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index ef976fd10eb..75524df2003 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -117,10 +117,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index) r.Get("/live/cloud", reqGrafanaAdmin, hs.Index) - r.Get("/plugins", reqSignedIn, hs.Index) - r.Get("/plugins/:id/", reqSignedIn, hs.Index) - r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) // deprecated - r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index) + r.Get("/plugins", middleware.CanAdminPlugins(hs.Cfg), hs.Index) + r.Get("/plugins/:id/", middleware.CanAdminPlugins(hs.Cfg), hs.Index) + r.Get("/plugins/:id/edit", middleware.CanAdminPlugins(hs.Cfg), hs.Index) // deprecated + r.Get("/plugins/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index) // App Root Page appPluginIDScope := plugins.ScopeProvider.GetResourceScope(ac.Parameter(":id")) r.Get("/a/:id/*", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, appPluginIDScope)), hs.Index) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index b7e00465586..9b0484eb51a 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/org" @@ -86,6 +87,15 @@ func EnsureEditorOrViewerCanEdit(c *models.ReqContext) { } } +func CanAdminPlugins(cfg *setting.Cfg) func(c *models.ReqContext) { + return func(c *models.ReqContext) { + if !plugins.ReqCanAdminPlugins(cfg)(c) { + accessForbidden(c) + return + } + } +} + func RoleAuth(roles ...org.RoleType) web.Handler { return func(c *models.ReqContext) { ok := false diff --git a/public/app/features/plugins/admin/routes.tsx b/public/app/features/plugins/admin/routes.tsx index 9e092654f1b..9f7154749ad 100644 --- a/public/app/features/plugins/admin/routes.tsx +++ b/public/app/features/plugins/admin/routes.tsx @@ -10,18 +10,21 @@ const DEFAULT_ROUTES = [ { path: '/plugins', navId: 'plugins', + roles: () => ['Admin', 'ServerAdmin'], routeName: PluginAdminRoutes.Home, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/plugins/browse', navId: 'plugins', + roles: () => ['Admin', 'ServerAdmin'], routeName: PluginAdminRoutes.Browse, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/plugins/:pluginId/', navId: 'plugins', + roles: () => ['Admin', 'ServerAdmin'], routeName: PluginAdminRoutes.Details, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')), }, From 5bb99775bb995436e9fcf723ed1e622b685a9f1f Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 30 Nov 2022 09:53:00 +0100 Subject: [PATCH 023/168] Fix: Unlocking the UI for AuthProxy users (#59507) Unlocking the UI for AuthProxy users Co-authored-by: Eric Leijonmarck Co-authored-by: Eric Leijonmarck --- public/app/features/admin/UserAdminPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 8a7f0599b5c..25fb7f584ea 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -112,9 +112,11 @@ export class UserAdminPage extends PureComponent { user?.isExternal && user?.authLabels?.some((r) => SyncedOAuthLabels.includes(r)); const isSAMLUser = user?.isExternal && user?.authLabels?.includes('SAML'); const isGoogleUser = user?.isExternal && user?.authLabels?.includes('Google'); + const isAuthProxyUser = user?.isExternal && user?.authLabels?.includes('Auth Proxy'); const isUserSynced = !config.auth.DisableSyncLock && - ((user?.isExternal && !(isGoogleUser || isOAuthUserWithSkippableSync || isSAMLUser || isLDAPUser)) || + ((user?.isExternal && + !(isAuthProxyUser || isGoogleUser || isOAuthUserWithSkippableSync || isSAMLUser || isLDAPUser)) || (!config.auth.OAuthSkipOrgRoleUpdateSync && isOAuthUserWithSkippableSync) || (!config.auth.SAMLSkipOrgRoleSync && isSAMLUser) || (!config.auth.LDAPSkipOrgRoleSync && isLDAPUser)); From 6aaf36776b06fa091696934dd4c730f755697743 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Wed, 30 Nov 2022 10:29:21 +0100 Subject: [PATCH 024/168] RBAC: Handle edge case where there is duplicated acl entries for a role on a single dashboard (#58079) * RBAC: Handle edge case where there is duplicated acl entries for a role on a single dashboard --- .../accesscontrol/dashboard_permissions.go | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go index aa20a878aeb..16c12e48f33 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go @@ -119,9 +119,10 @@ func (m dashboardPermissionsMigrator) migratePermissions(dashboards []dashboard, m.mapPermission(d.ID, models.PERMISSION_VIEW, d.IsFolder)..., ) } else { - for _, a := range acls { - permissionMap[d.OrgID][getRoleName(a)] = append( - permissionMap[d.OrgID][getRoleName(a)], + for _, a := range deduplicateAcl(acls) { + roleName := getRoleName(a) + permissionMap[d.OrgID][roleName] = append( + permissionMap[d.OrgID][roleName], m.mapPermission(d.ID, a.Permission, d.IsFolder)..., ) } @@ -224,6 +225,39 @@ func getRoleName(p models.DashboardACL) string { return fmt.Sprintf("managed:builtins:%s:permissions", strings.ToLower(string(*p.Role))) } +func deduplicateAcl(acl []models.DashboardACL) []models.DashboardACL { + output := make([]models.DashboardACL, 0, len(acl)) + uniqueACL := map[string]models.DashboardACL{} + for _, item := range acl { + // acl items with userID or teamID is enforced to be unique by sql constraint, so we can skip those + if item.UserID > 0 || item.TeamID > 0 { + output = append(output, item) + continue + } + + // better to make sure so we don't panic + if item.Role == nil { + continue + } + + current, ok := uniqueACL[string(*item.Role)] + if !ok { + uniqueACL[string(*item.Role)] = item + continue + } + + if current.Permission < item.Permission { + uniqueACL[string(*item.Role)] = item + } + } + + for _, item := range uniqueACL { + output = append(output, item) + } + + return output +} + var _ migrator.CodeMigration = new(dashboardUidPermissionMigrator) type dashboardUidPermissionMigrator struct { From 005d0f852fa7334f07d6033da301e6b75124cac9 Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Wed, 30 Nov 2022 11:30:06 +0200 Subject: [PATCH 025/168] Changelog: Updated changelog for 9.3.0 (#59533) --- CHANGELOG.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e6d7bda36..9f819093c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,68 @@ + + +# 9.3.0 (2022-11-30) + +### Features and enhancements + +- **Alerting:** Enable interpolation for notification policies in file provisioning. [#58956](https://github.com/grafana/grafana/pull/58956), [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) +- **Azure Monitor Logs:** Avoid warning when the response is empty. [#59211](https://github.com/grafana/grafana/pull/59211), [@andresmgot](https://github.com/andresmgot) +- **Azure Monitor:** Add support to customized routes. [#54829](https://github.com/grafana/grafana/pull/54829), [@ms-hujia](https://github.com/ms-hujia) +- **Canvas:** Add icon value mapping. [#59013](https://github.com/grafana/grafana/pull/59013), [@nmarrs](https://github.com/nmarrs) +- **CloudWatch:** Cross-account querying support. [#59362](https://github.com/grafana/grafana/pull/59362), [@sunker](https://github.com/sunker) +- **Docs:** Update `merge-pull-request.md` regarding backport policies. [#59239](https://github.com/grafana/grafana/pull/59239), [@dsotirakis](https://github.com/dsotirakis) +- **GaugePanel:** Setting the neutral-point of a gauge. [#53989](https://github.com/grafana/grafana/pull/53989), [@sfranzis](https://github.com/sfranzis) +- **Geomap:** Improve location editor. [#58017](https://github.com/grafana/grafana/pull/58017), [@drew08t](https://github.com/drew08t) +- **Internationalization:** Enable internationalization by default. [#59204](https://github.com/grafana/grafana/pull/59204), [@joshhunt](https://github.com/joshhunt) +- **Logs:** Add `Download logs` button to log log-browser. [#55163](https://github.com/grafana/grafana/pull/55163), [@svennergr](https://github.com/svennergr) +- **Loki:** Add `gzip` compression to resource calls. [#59059](https://github.com/grafana/grafana/pull/59059), [@svennergr](https://github.com/svennergr) +- **Loki:** Add improvements to loki label browser. [#59387](https://github.com/grafana/grafana/pull/59387), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Make label browser accessible in query builder. [#58525](https://github.com/grafana/grafana/pull/58525), [@gwdawson](https://github.com/gwdawson) +- **Loki:** Remove raw query toggle. [#59125](https://github.com/grafana/grafana/pull/59125), [@gwdawson](https://github.com/gwdawson) +- **Middleware:** Add CSP Report Only support. [#58074](https://github.com/grafana/grafana/pull/58074), [@jcalisto](https://github.com/jcalisto) +- **Navigation:** Prevent viewer role accessing dashboard creation, import and folder creation. [#58842](https://github.com/grafana/grafana/pull/58842), [@lpskdl](https://github.com/lpskdl) +- **OAuth:** Refactor OAuth parameters handling to support obtaining refresh tokens for Google OAuth. [#58782](https://github.com/grafana/grafana/pull/58782), [@mgyongyosi](https://github.com/mgyongyosi) +- **Oauth:** Display friendly error message when role_attribute_strict=true and no valid role found. [#57818](https://github.com/grafana/grafana/pull/57818), [@kalleep](https://github.com/kalleep) +- **Preferences:** Add confirmation modal when saving org preferences. [#59119](https://github.com/grafana/grafana/pull/59119), [@JoaoSilvaGrafana](https://github.com/JoaoSilvaGrafana) +- **PublicDashboards:** Orphaned public dashboard deletion script added. [#57917](https://github.com/grafana/grafana/pull/57917), [@juanicabanas](https://github.com/juanicabanas) +- **Query Editor:** Hide overflow for long query names. [#58840](https://github.com/grafana/grafana/pull/58840), [@zuchka](https://github.com/zuchka) +- **Reports:** Configurable timezone. (Enterprise) +- **Solo Panel:** Configurable timezone. [#59153](https://github.com/grafana/grafana/pull/59153), [@spinillos](https://github.com/spinillos) +- **TablePanel:** Add support for Count calculation per column or per entire dataset. [#58134](https://github.com/grafana/grafana/pull/58134), [@mdvictor](https://github.com/mdvictor) +- **Tempo:** Send the correct start time when making a TraceQL query. [#59128](https://github.com/grafana/grafana/pull/59128), [@CrypticSignal](https://github.com/CrypticSignal) +- **Various Panels:** Remove beta label from Bar Chart, Candlestick, Histogram, State Timeline, & Status History Panels. [#58557](https://github.com/grafana/grafana/pull/58557), [@codeincarnate](https://github.com/codeincarnate) + +### Bug fixes + +- **Access Control:** Clear user's permission cache after resource creation. [#59307](https://github.com/grafana/grafana/pull/59307), [@grafanabot](https://github.com/grafanabot) +- **Access Control:** Clear user's permission cache after resource creation. [#59101](https://github.com/grafana/grafana/pull/59101), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **Accessibility:** Improve keyboard accessibility in `AnnoListPanel`. [#58971](https://github.com/grafana/grafana/pull/58971), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility in `Collapse`. [#59022](https://github.com/grafana/grafana/pull/59022), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility in `GettingStarted` panel. [#58966](https://github.com/grafana/grafana/pull/58966), [@ashharrison90](https://github.com/ashharrison90) +- **Accessibility:** Improve keyboard accessibility of `FilterPill`. [#58976](https://github.com/grafana/grafana/pull/58976), [@ashharrison90](https://github.com/ashharrison90) +- **Admin:** Fix broken links to image assets in email templates. [#58729](https://github.com/grafana/grafana/pull/58729), [@zuchka](https://github.com/zuchka) +- **Azure Monitor:** Fix namespace selection for storageaccounts. [#56449](https://github.com/grafana/grafana/pull/56449), [@andresmgot](https://github.com/andresmgot) +- **Calcs:** Fix difference percent in legend. [#59243](https://github.com/grafana/grafana/pull/59243), [@zoltanbedi](https://github.com/zoltanbedi) +- **DataLinks:** Improve Data-Links AutoComplete Logic. [#58934](https://github.com/grafana/grafana/pull/58934), [@zuchka](https://github.com/zuchka) +- **Explore:** Fix a11y issue with logs navigation buttons. [#58944](https://github.com/grafana/grafana/pull/58944), [@Elfo404](https://github.com/Elfo404) +- **Heatmap:** Fix blurry text & rendering. [#59260](https://github.com/grafana/grafana/pull/59260), [@leeoniya](https://github.com/leeoniya) +- **Heatmap:** Fix tooltip y range of top and bottom buckets in calculated heatmaps. [#59172](https://github.com/grafana/grafana/pull/59172), [@leeoniya](https://github.com/leeoniya) +- **Logs:** Fix misalignment of LogRows. [#59279](https://github.com/grafana/grafana/pull/59279), [@svennergr](https://github.com/svennergr) +- **Navigation:** Stop clearing search state when opening a result in a new tab. [#58880](https://github.com/grafana/grafana/pull/58880), [@ashharrison90](https://github.com/ashharrison90) +- **OptionsUI:** SliderValueEditor does not get auto focused on slider change. [#59209](https://github.com/grafana/grafana/pull/59209), [@eledobleefe](https://github.com/eledobleefe) +- **PanelEdit:** Fixes bug with not remembering panel options pane collapse/expand state. [#59265](https://github.com/grafana/grafana/pull/59265), [@torkelo](https://github.com/torkelo) +- **Query Caching:** Skip 207 status codes. (Enterprise) +- **Quota:** Fix failure in store due to missing scope parameters. [#58874](https://github.com/grafana/grafana/pull/58874), [@papagian](https://github.com/papagian) +- **Quota:** Fix failure when checking session limits. [#58865](https://github.com/grafana/grafana/pull/58865), [@papagian](https://github.com/papagian) +- **Reports:** Fix time preview. (Enterprise) +- **StateTimeline:** Prevent label text from overflowing state rects. [#59169](https://github.com/grafana/grafana/pull/59169), [@leeoniya](https://github.com/leeoniya) +- **Tempo:** Fix search table duration unit. [#58642](https://github.com/grafana/grafana/pull/58642), [@joey-grafana](https://github.com/joey-grafana) +- **TraceView:** Fix broken rendering when scrolling in Dashboard panel in Firefox. [#56642](https://github.com/grafana/grafana/pull/56642), [@zdg-github](https://github.com/zdg-github) + +### Plugin development fixes & changes + +- **GrafanaUI:** Add disabled option for menu items. [#58980](https://github.com/grafana/grafana/pull/58980), [@going-confetti](https://github.com/going-confetti) + + # 9.3.0-beta1 (2022-11-15) From d11c1ee6464032399b17f7934e419efa1cd9be70 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Wed, 30 Nov 2022 11:52:36 +0200 Subject: [PATCH 026/168] Chore: Update latest.json to `9.3.0` (#59538) Chore: update latest.json to 9.3.0 --- latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/latest.json b/latest.json index 5d0d253a775..8b47f75b668 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { - "stable": "9.2.6", - "testing": "9.3.0-beta1" + "stable": "9.3.0", + "testing": "9.3.0" } From 749eb9ed198f7dcc182acec5e7ed53b38bb1abcb Mon Sep 17 00:00:00 2001 From: Hamas Shafiq Date: Wed, 30 Nov 2022 10:12:36 +0000 Subject: [PATCH 027/168] Chore: Delete UiFindInput.test.js (#59228) --- .../src/common/UiFindInput.test.js | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 packages/jaeger-ui-components/src/common/UiFindInput.test.js diff --git a/packages/jaeger-ui-components/src/common/UiFindInput.test.js b/packages/jaeger-ui-components/src/common/UiFindInput.test.js deleted file mode 100644 index 43f3fad8869..00000000000 --- a/packages/jaeger-ui-components/src/common/UiFindInput.test.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2019 Uber Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { render, screen } from '@testing-library/react'; -import * as React from 'react'; - -import UiFindInput from './UiFindInput'; - -describe('UiFindInput', () => { - describe('rendering', () => { - it('renders as expected with no value', () => { - render(); - const uiFindInput = screen.queryByPlaceholderText('Find...'); - expect(uiFindInput).toBeInTheDocument(); - expect(uiFindInput['value']).toEqual(''); - }); - - it('renders as expected with value', () => { - render(); - const uiFindInput = screen.queryByPlaceholderText('Find...'); - expect(uiFindInput).toBeInTheDocument(); - expect(uiFindInput['value']).toEqual('value'); - }); - }); -}); From 695cb06c77a24ef00905f29f152659fce8ac03dd Mon Sep 17 00:00:00 2001 From: sam boyer Date: Wed, 30 Nov 2022 05:24:51 -0500 Subject: [PATCH 028/168] deps: Remove effectless go.mod replace statements (#58882) --- go.mod | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/go.mod b/go.mod index 0d538e74535..cb798730c8b 100644 --- a/go.mod +++ b/go.mod @@ -368,25 +368,11 @@ require ( // Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream replace github.com/crewjam/saml => github.com/grafana/saml v0.4.9-0.20220727151557-61cd9c9353fc -replace github.com/apache/thrift => github.com/apache/thrift v0.14.1 - -replace github.com/hashicorp/consul => github.com/hashicorp/consul v1.10.2 - -replace github.com/gin-gonic/gin => github.com/gin-gonic/gin v1.7.7 - -replace github.com/tidwall/gjson => github.com/tidwall/gjson v1.14.1 - -// Upgraded to fix CVE-2020-26066. This can be removed when go.opentelemetry.io/collector and github.com/influxdata/telegraf are upgraded -// github.com/tidwall/match v1.0.1 should not be used. -replace github.com/tidwall/match => github.com/tidwall/match v1.1.1 - // Thema's thema CLI requires cobra, which eventually works its way down to go-hclog@v1.0.0. // Upgrading affects backend plugins: https://github.com/grafana/grafana/pull/47653#discussion_r850508593 // No harm to Thema because it's only a dependency in its main package. replace github.com/hashicorp/go-hclog => github.com/hashicorp/go-hclog v0.16.1 -replace github.com/microcosm-cc/bluemonday => github.com/microcosm-cc/bluemonday v1.0.18 - // This is a patched v0.8.2 intended to fix session.Find (and others) silently ignoring SQLITE_BUSY errors. This could // happen, for example, during a read when the sqlite db is under heavy write load. // This patch cherry picks compatible fixes from upstream xorm PR#1998 and can be reverted on upgrade to xorm v1.2.0+. From eaa4d19ed03ac0a095623898d2e0e8dd95e660b1 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 30 Nov 2022 10:27:41 +0000 Subject: [PATCH 029/168] Navigation: Move k6 to top-level, rename to "Performance testing" (#59481) Move k6 to top-level, rename to "Performance testing" --- pkg/services/navtree/navtreeimpl/applinks.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 5bce8426b88..1f63cdde59f 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -263,6 +263,7 @@ func (s *ServiceImpl) readNavigationSettings() { "grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfg}, "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightSavedItems + 1, Text: "Connections"}, + "grafana-k6-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAlertsAndIncidents + 1, Text: "Performance testing"}, } s.navigationAppPathConfig = map[string]NavigationAppConfig{ From 701d1b135e18fb0814007a62e3af2479b5a1bc0a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 30 Nov 2022 10:30:06 +0000 Subject: [PATCH 030/168] Update dependency rc-drawer to v6 (#58237) * Update dependency rc-drawer to v6 * updates for rc-drawer v6 * move aria-label to an inner child to fix e2e tests Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison --- package.json | 2 +- .../src/selectors/components.ts | 2 +- packages/grafana-ui/package.json | 2 +- .../src/components/Drawer/Drawer.tsx | 88 ++++++++++++------- yarn.lock | 52 ++++++++--- 5 files changed, 101 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index 98fbcb97bea..77c1594db5e 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "prop-types": "15.8.1", "pseudoizer": "^0.1.0", "rc-cascader": "3.7.0", - "rc-drawer": "4.4.3", + "rc-drawer": "6.0.1", "rc-slider": "10.0.1", "rc-time-picker": "3.7.3", "rc-tree": "5.7.0", diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 724c15d6c2b..28df5e9f7fe 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -107,7 +107,7 @@ export const Components = { expand: 'Drawer expand', contract: 'Drawer contract', close: 'Drawer close', - rcContentWrapper: () => '.drawer-content-wrapper', + rcContentWrapper: () => '.rc-drawer-content-wrapper', }, }, PanelEditor: { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index bc938f89625..18302142bd1 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -79,7 +79,7 @@ "ol": "7.1.0", "prismjs": "1.29.0", "rc-cascader": "3.7.0", - "rc-drawer": "4.4.3", + "rc-drawer": "6.0.1", "rc-slider": "10.0.1", "rc-time-picker": "^3.7.3", "rc-tooltip": "5.2.2", diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 9eabed2d788..7c5efda7ba7 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -74,24 +74,37 @@ export function Drawer({ return ( -
+
{typeof title === 'string' && (
@@ -145,33 +158,46 @@ const getStyles = (theme: GrafanaTheme2) => { flex: 1 1 0; `, drawer: css` - .drawer-content { - background-color: ${theme.colors.background.primary}; - display: flex; - flex-direction: column; - overflow: hidden; - } - &.drawer-open .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - opacity: 1; - } - .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - } - .drawer-open .drawer-content-wrapper { + .rc-drawer-content-wrapper { box-shadow: ${theme.shadows.z3}; - } - z-index: ${theme.zIndex.dropdown}; - - ${theme.breakpoints.down('sm')} { - .drawer-content-wrapper { + ${theme.breakpoints.down('sm')} { width: 100% !important; } } `, + drawerContent: css` + background-color: ${theme.colors.background.primary} !important; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: ${theme.zIndex.dropdown}; + `, + drawerMotion: css` + &-appear { + transform: translateX(100%); + transition: none !important; + + &-active { + transition: ${theme.transitions.create('transform')} !important; + transform: translateX(0); + } + } + `, + mask: css` + background-color: ${theme.components.overlay.background} !important; + backdrop-filter: blur(1px); + `, + maskMotion: css` + &-appear { + opacity: 0; + + &-active { + opacity: 1; + transition: ${theme.transitions.create('opacity')}; + } + } + `, header: css` background-color: ${theme.colors.background.canvas}; flex-grow: 0; diff --git a/yarn.lock b/yarn.lock index 8718f167344..0f43ddfc7cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2967,7 +2967,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:7.20.1": +"@babel/runtime@npm:7.20.1, @babel/runtime@npm:^7.18.0": version: 7.20.1 resolution: "@babel/runtime@npm:7.20.1" dependencies: @@ -4777,7 +4777,7 @@ __metadata: prismjs: 1.29.0 process: ^0.11.10 rc-cascader: 3.7.0 - rc-drawer: 4.4.3 + rc-drawer: 6.0.1 rc-slider: 10.0.1 rc-time-picker: ^3.7.3 rc-tooltip: 5.2.2 @@ -7496,6 +7496,20 @@ __metadata: languageName: node linkType: hard +"@rc-component/portal@npm:^1.0.0-6": + version: 1.0.3 + resolution: "@rc-component/portal@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.18.0 + classnames: ^2.3.2 + rc-util: ^5.24.4 + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: e25a72042c4a7dfe8a25526972e1d024a8f5aac29963bed63ecdb356b238ae882575b2dc27de9f003618638c758dc669d5684549cf09125fce95d9227d78a75e + languageName: node + linkType: hard + "@reach/observe-rect@npm:^1.1.0": version: 1.2.0 resolution: "@reach/observe-rect@npm:1.2.0" @@ -15478,7 +15492,7 @@ __metadata: languageName: node linkType: hard -"classnames@npm:2.3.2": +"classnames@npm:2.3.2, classnames@npm:^2.3.2": version: 2.3.2 resolution: "classnames@npm:2.3.2" checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e @@ -21713,7 +21727,7 @@ __metadata: prop-types: 15.8.1 pseudoizer: ^0.1.0 rc-cascader: 3.7.0 - rc-drawer: 4.4.3 + rc-drawer: 6.0.1 rc-slider: 10.0.1 rc-time-picker: 3.7.3 rc-tree: 5.7.0 @@ -31664,17 +31678,19 @@ __metadata: languageName: node linkType: hard -"rc-drawer@npm:4.4.3": - version: 4.4.3 - resolution: "rc-drawer@npm:4.4.3" +"rc-drawer@npm:6.0.1": + version: 6.0.1 + resolution: "rc-drawer@npm:6.0.1" dependencies: "@babel/runtime": ^7.10.1 + "@rc-component/portal": ^1.0.0-6 classnames: ^2.2.6 - rc-util: ^5.7.0 + rc-motion: ^2.6.1 + rc-util: ^5.21.2 peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: bb0b3932dbe351f67c21221d08612272854166d84c4425beda365e17d89c356aecfd964f7f6451a19eb31034c4b22e515939bef1319a067c2a669541e1658084 + checksum: ce4a0b2ac3a96a203a1038f1c30df079e34359f821db9bcab39a87bdc62e62dd681d3b37354d9e1c77b44e3954b4f583d3b16ed021bcd1851dea0f62b888f6a8 languageName: node linkType: hard @@ -31692,6 +31708,20 @@ __metadata: languageName: node linkType: hard +"rc-motion@npm:^2.6.1": + version: 2.6.2 + resolution: "rc-motion@npm:2.6.2" + dependencies: + "@babel/runtime": ^7.11.1 + classnames: ^2.2.1 + rc-util: ^5.21.0 + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: e91ec8a9f8748ae34d6f9c0380d4587729453c7c8afe23c026ff096905b5a24672b050e04789061c833994e05ed18fec02919bc0e27c1e05b06fe7a0c0b75532 + languageName: node + linkType: hard + "rc-overflow@npm:^1.0.0": version: 1.2.2 resolution: "rc-overflow@npm:1.2.2" @@ -31889,7 +31919,7 @@ __metadata: languageName: node linkType: hard -"rc-util@npm:^5.18.1, rc-util@npm:^5.19.2": +"rc-util@npm:^5.18.1, rc-util@npm:^5.19.2, rc-util@npm:^5.21.0, rc-util@npm:^5.21.2, rc-util@npm:^5.24.4": version: 5.24.4 resolution: "rc-util@npm:5.24.4" dependencies: @@ -31903,7 +31933,7 @@ __metadata: languageName: node linkType: hard -"rc-util@npm:^5.2.1, rc-util@npm:^5.3.0, rc-util@npm:^5.5.0, rc-util@npm:^5.7.0": +"rc-util@npm:^5.2.1, rc-util@npm:^5.3.0, rc-util@npm:^5.5.0": version: 5.14.0 resolution: "rc-util@npm:5.14.0" dependencies: From ba0b2dfa1a0087d123464b4b231d2b5f462310c1 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Wed, 30 Nov 2022 12:37:16 +0200 Subject: [PATCH 031/168] Security: Fix XSS in runbook URL (#59540) Fix XSS in runbook URL Co-authored-by: George Robinson --- .../unified/components/rules/RuleDetailsActionButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 00439bb23bd..cd66f5547fd 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { FC, Fragment } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, textUtil } from '@grafana/data'; import { Button, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; @@ -61,7 +61,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { variant="primary" icon="book" target="__blank" - href={rule.annotations[Annotation.runbookURL]} + href={textUtil.sanitizeUrl(rule.annotations[Annotation.runbookURL])} > View runbook From 0b42b2f790ec015a75a9c99503a1035462c1799d Mon Sep 17 00:00:00 2001 From: Hamas Shafiq Date: Wed, 30 Nov 2022 12:08:02 +0000 Subject: [PATCH 032/168] Chore: Refactor TracePageHeader.test.js to TypeScript (#59256) --- ...eader.test.js => TracePageHeader.test.tsx} | 82 +++++++++---------- .../src/TracePageHeader/TracePageHeader.tsx | 4 +- 2 files changed, 39 insertions(+), 47 deletions(-) rename packages/jaeger-ui-components/src/TracePageHeader/{TracePageHeader.test.js => TracePageHeader.test.tsx} (58%) diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx similarity index 58% rename from packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js rename to packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx index bb6955051dd..65f8dd706d8 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.js +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.test.tsx @@ -19,20 +19,22 @@ import traceGenerator from '../demo/trace-generators'; import { getTraceName } from '../model/trace-viewer'; import transformTraceData from '../model/transform-trace-data'; -import TracePageHeader from './TracePageHeader'; +import TracePageHeader, { TracePageHeaderEmbedProps } from './TracePageHeader'; const trace = transformTraceData(traceGenerator.trace({})); -const setup = (propOverrides) => { +const setup = (propOverrides?: TracePageHeaderEmbedProps) => { const defaultProps = { + canCollapse: false, + hideSummary: false, + onSlimViewClicked: () => {}, + onTraceGraphViewClicked: () => {}, + slimView: false, trace, hideMap: false, - showArchiveButton: false, - showShortcutsHelp: false, - showStandaloneLink: false, - showViewOptions: false, - textFilter: '', - viewRange: { time: { current: [10, 20] } }, - updateTextFilter: () => {}, + timeZone: '', + viewRange: { time: { current: [10, 20] as [number, number] } }, + updateNextViewRangeTime: () => {}, + updateViewRangeTime: () => {}, ...propOverrides, }; @@ -42,23 +44,23 @@ const setup = (propOverrides) => { describe('TracePageHeader test', () => { it('should render a header ', () => { setup(); - expect(screen.getByRole('banner')).toBeInTheDocument(); }); it('should render nothing if a trace is not present', () => { - setup({ trace: null }); - + setup({ trace: null } as TracePageHeaderEmbedProps); expect(screen.queryByRole('banner')).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { traceName: getTraceName(trace.spans) })).not.toBeInTheDocument(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument(); }); it('should render the trace title', () => { setup(); - - expect(screen.getByRole('heading', { traceName: getTraceName(trace.spans) })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + name: (content) => content.replace(/ /g, '').startsWith(getTraceName(trace!.spans).replace(/ /g, '')), + }) + ).toBeInTheDocument(); }); it('should render the header items', () => { @@ -68,61 +70,51 @@ describe('TracePageHeader test', () => { expect(headerItems).toHaveLength(5); // Year-month-day hour-minute-second - expect(headerItems[0].textContent.match(/Trace Start:\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/g)).toBeTruthy(); - expect(headerItems[1].textContent.match(/Duration:[\d|\.][\.|\d|s][\.|\d|s]?[\d]?/)).toBeTruthy(); - expect(headerItems[2].textContent.match(/Services:\d\d?/g)).toBeTruthy(); - expect(headerItems[3].textContent.match(/Depth:\d\d?/)).toBeTruthy(); - expect(headerItems[4].textContent.match(/Total Spans:\d\d?\d?\d?/)).toBeTruthy(); + expect(headerItems[0].textContent?.match(/Trace Start:\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/g)).toBeTruthy(); + expect(headerItems[1].textContent?.match(/Duration:[\d|\.][\.|\d|s][\.|\d|s]?[\d]?/)).toBeTruthy(); + expect(headerItems[2].textContent?.match(/Services:\d\d?/g)).toBeTruthy(); + expect(headerItems[3].textContent?.match(/Depth:\d\d?/)).toBeTruthy(); + expect(headerItems[4].textContent?.match(/Total Spans:\d\d?\d?\d?/)).toBeTruthy(); }); it('should render a ', () => { setup(); - expect(screen.getByText(/Reset Selection/)).toBeInTheDocument(); }); describe('observes the visibility toggles for various UX elements', () => { it('hides the minimap when hideMap === true', () => { - setup({ hideMap: true }); - + setup({ hideMap: true } as TracePageHeaderEmbedProps); expect(screen.queryByText(/Reset Selection/)).not.toBeInTheDocument(); }); it('hides the summary when hideSummary === true', () => { - const { rerender } = setup({ hideSummary: false }); + const { rerender } = setup({ hideSummary: false } as TracePageHeaderEmbedProps); expect(screen.queryAllByRole('listitem')).toHaveLength(5); - rerender(); + rerender(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); rerender( {}} + {...({ + trace: trace, + hideSummary: true, + hideMap: false, + viewRange: { time: { current: [10, 20] } }, + } as TracePageHeaderEmbedProps)} /> ); expect(screen.queryAllByRole('listitem')).toHaveLength(0); rerender( {}} + {...({ + trace: trace, + hideSummary: false, + hideMap: false, + viewRange: { time: { current: [10, 20] } }, + } as TracePageHeaderEmbedProps)} /> ); expect(screen.queryAllByRole('listitem')).toHaveLength(5); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index 5c78064d18f..a18fb38e213 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -140,14 +140,14 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -type TracePageHeaderEmbedProps = { +export type TracePageHeaderEmbedProps = { canCollapse: boolean; hideMap: boolean; hideSummary: boolean; onSlimViewClicked: () => void; onTraceGraphViewClicked: () => void; slimView: boolean; - trace: Trace; + trace: Trace | null; updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; updateViewRangeTime: TUpdateViewRangeTimeFunction; viewRange: ViewRange; From ddc3706f19be97c7d9fca80a80a81781b4fe9235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 30 Nov 2022 13:20:59 +0100 Subject: [PATCH 033/168] Accessibility: Increase badge constrast to be WCAG AA compliant (#59531) --- packages/grafana-ui/src/components/Badge/Badge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Badge/Badge.tsx b/packages/grafana-ui/src/components/Badge/Badge.tsx index b97dbbf7499..a5f07e76835 100644 --- a/packages/grafana-ui/src/components/Badge/Badge.tsx +++ b/packages/grafana-ui/src/components/Badge/Badge.tsx @@ -54,7 +54,7 @@ const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => { } else { bgColor = tinycolor(sourceColor).setAlpha(0.15).toString(); borderColor = tinycolor(sourceColor).lighten(20).toString(); - textColor = tinycolor(sourceColor).darken(15).toString(); + textColor = tinycolor(sourceColor).darken(20).toString(); } return { From 32a498e04f265756fb1583f57f3f55f64830b3a7 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 30 Nov 2022 13:55:07 +0100 Subject: [PATCH 034/168] RBAC: Validate plugin app access permission targets the plugin (#59468) * RBAC: Validate plugin app access permission targets the plugin * Fix service test --- pkg/services/accesscontrol/acimpl/service_test.go | 2 +- pkg/services/accesscontrol/errors.go | 14 ++++++++++++++ pkg/services/accesscontrol/pluginutils/utils.go | 5 +++++ .../accesscontrol/pluginutils/utils_test.go | 13 ++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index c635d506e91..09bd7435afa 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -189,7 +189,7 @@ func TestService_DeclarePluginRoles(t *testing.T) { Role: plugins.Role{ Name: "Tester", Permissions: []plugins.Permission{ - {Action: "plugins.app:access"}, + {Action: "plugins.app:access", Scope: "plugins:id:test-app"}, {Action: "test-app:read"}, {Action: "test-app.resource:read"}, }, diff --git a/pkg/services/accesscontrol/errors.go b/pkg/services/accesscontrol/errors.go index 91f06a38440..27047abb596 100644 --- a/pkg/services/accesscontrol/errors.go +++ b/pkg/services/accesscontrol/errors.go @@ -44,3 +44,17 @@ func (e *ErrorActionPrefixMissing) Error() string { func (e *ErrorActionPrefixMissing) Unwrap() error { return &ErrorInvalidRole{} } + +type ErrorScopeTarget struct { + Action string + Scope string + ExpectedScope string +} + +func (e *ErrorScopeTarget) Error() string { + return fmt.Sprintf("expected action '%s' to be scoped with '%v', found '%v'", e.Action, e.ExpectedScope, e.Scope) +} + +func (e *ErrorScopeTarget) Unwrap() error { + return &ErrorInvalidRole{} +} diff --git a/pkg/services/accesscontrol/pluginutils/utils.go b/pkg/services/accesscontrol/pluginutils/utils.go index eac5effdcf0..b246706668f 100644 --- a/pkg/services/accesscontrol/pluginutils/utils.go +++ b/pkg/services/accesscontrol/pluginutils/utils.go @@ -17,6 +17,11 @@ func ValidatePluginPermissions(pluginID string, permissions []ac.Permission) err return &ac.ErrorActionPrefixMissing{Action: permissions[i].Action, Prefixes: []string{plugins.ActionAppAccess, pluginID + ":", pluginID + "."}} } + if strings.HasPrefix(permissions[i].Action, plugins.ActionAppAccess) && + permissions[i].Scope != plugins.ScopeProvider.GetResourceScope(pluginID) { + return &ac.ErrorScopeTarget{Action: permissions[i].Action, Scope: permissions[i].Scope, + ExpectedScope: plugins.ScopeProvider.GetResourceScope(pluginID)} + } } return nil diff --git a/pkg/services/accesscontrol/pluginutils/utils_test.go b/pkg/services/accesscontrol/pluginutils/utils_test.go index 8722c8e747a..0284ebbb49e 100644 --- a/pkg/services/accesscontrol/pluginutils/utils_test.go +++ b/pkg/services/accesscontrol/pluginutils/utils_test.go @@ -122,12 +122,23 @@ func TestValidatePluginRole(t *testing.T) { role: ac.RoleDTO{ Name: "plugins:test-app:reader", Permissions: []ac.Permission{ - {Action: "plugins.app:access"}, + {Action: "plugins.app:access", Scope: "plugins:id:test-app"}, {Action: "test-app:read"}, {Action: "test-app.resources:read"}, }, }, }, + { + name: "invalid permission targets other plugin", + pluginID: "test-app", + role: ac.RoleDTO{ + Name: "plugins:test-app:reader", + Permissions: []ac.Permission{ + {Action: "plugins.app:access", Scope: "plugins:id:other-app"}, + }, + }, + wantErr: &ac.ErrorInvalidRole{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From c3d13a0e2f66bb023cef6f45e9156384a95bfd02 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 30 Nov 2022 15:24:53 +0200 Subject: [PATCH 035/168] Admin: Combine org and admin user pages (#59365) * Admin: Add unified users page * Admin: Combine admin and org components * Admin: Add combined route * Admin: Show combined page in nav * Admin: Update translation * Admin: Update description * Admin: Update description on backend * Admin: Update translations * Admin: Use dynamic imports --- pkg/api/api.go | 7 +- pkg/services/navtree/models.go | 3 +- pkg/services/navtree/navtreeimpl/admin.go | 34 ++-- .../NavBar/navBarItem-translations.ts | 4 +- .../app/features/admin/UserListAdminPage.tsx | 177 +++++++++--------- public/app/features/admin/UserListPage.tsx | 52 +++++ .../app/features/users/UsersListPage.test.tsx | 4 +- public/app/features/users/UsersListPage.tsx | 30 +-- public/app/routes/routes.tsx | 8 +- public/locales/en-US/grafana.json | 4 +- public/locales/fr-FR/grafana.json | 8 +- public/locales/pseudo-LOCALE/grafana.json | 4 +- 12 files changed, 207 insertions(+), 128 deletions(-) create mode 100644 public/app/features/admin/UserListPage.tsx diff --git a/pkg/api/api.go b/pkg/api/api.go index 75524df2003..7b5988351de 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -103,7 +103,12 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/configuration", reqGrafanaAdmin, hs.Index) r.Get("/admin", reqGrafanaAdmin, hs.Index) r.Get("/admin/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Index) - r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) + // Show the combined users page for org admins if topnav is enabled + if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { + r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index) + } else { + r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) + } r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index e73929d123a..276318892c2 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -198,9 +198,8 @@ func ApplyAdminIA(root *NavTreeRoot) { pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app")) pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries")) // enterprise only - accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("users")) if globalUsers := root.FindById("global-users"); globalUsers != nil { - globalUsers.Text = "Users (All orgs)" + globalUsers.Text = "Users" accessNodeLinks = append(accessNodeLinks, globalUsers) } accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams")) diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 7bf7a95ec46..0429cd01276 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -36,14 +36,16 @@ func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, e }) } - if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { - configNodes = append(configNodes, &navtree.NavLink{ - Text: "Users", - Id: "users", - SubTitle: "Invite and assign roles to users", - Icon: "user", - Url: s.cfg.AppSubURL + "/org/users", - }) + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Users", + Id: "users", + SubTitle: "Invite and assign roles to users", + Icon: "user", + Url: s.cfg.AppSubURL + "/org/users", + }) + } } if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) { @@ -123,10 +125,18 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead) adminNavLinks := []*navtree.NavLink{} - if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { - adminNavLinks = append(adminNavLinks, &navtree.NavLink{ - Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", - }) + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", + }) + } + } else { + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", + }) + } } if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { diff --git a/public/app/core/components/NavBar/navBarItem-translations.ts b/public/app/core/components/NavBar/navBarItem-translations.ts index ea674073cf7..9fc0820c0e0 100644 --- a/public/app/core/components/NavBar/navBarItem-translations.ts +++ b/public/app/core/components/NavBar/navBarItem-translations.ts @@ -97,7 +97,7 @@ export function getNavTitle(navId: string | undefined) { return t('nav.admin.title', 'Server admin'); case 'global-users': return config.featureToggles.topnav - ? t('nav.global-users.title', 'Users (All orgs)') + ? t('nav.global-users.title', 'Users') : t('nav.global-users.titleBeforeTopnav', 'Users'); case 'global-orgs': return t('nav.global-orgs.title', 'Organizations'); @@ -184,7 +184,7 @@ export function getNavSubTitle(navId: string | undefined) { case 'serviceaccounts': return t('nav.service-accounts.subtitle', 'Use service accounts to run automated workloads in Grafana'); case 'global-users': - return t('nav.global-users.subtitle', 'Manage and create users across the whole Grafana server'); + return t('nav.global-users.subtitle', 'Manage users in Grafana'); case 'global-orgs': return t('nav.global-orgs.subtitle', 'Isolated instances of Grafana running on the same server'); case 'server-settings': diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 308c6841427..a1119b509d9 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -77,98 +77,105 @@ const UserListAdminPageUnConnected = ({ const showLicensedRole = useMemo(() => users.some((user) => user.licensedRole), [users]); return ( - - -
-
- - changeFilter({ name: 'activeLast30Days', value })} - value={filters.find((f) => f.name === 'activeLast30Days')?.value} - className={styles.filter} - /> - {extraFilters.map((FilterComponent, index) => ( - - ))} -
- {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( - - New user - - )} + +
+
+ + changeFilter({ name: 'activeLast30Days', value })} + value={filters.find((f) => f.name === 'activeLast30Days')?.value} + className={styles.filter} + /> + {extraFilters.map((FilterComponent, index) => ( + + ))}
- {isLoading ? ( - - ) : ( - <> -
-
- - - - - - - - {showLicensedRole && ( - - )} + {contextSrv.hasPermission(AccessControlAction.UsersCreate) && ( + + New user + + )} + + {isLoading ? ( + + ) : ( + <> +
+
LoginEmailNameBelongs to - Licensed role{' '} - - Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their - dashboard/folder permissions.{' '} - - Learn more - - - } - > - - -
+ + + + + + + + {showLicensedRole && ( - - - - - {users.map((user) => ( - - ))} - -
LoginEmailNameBelongs to - Last active  - + Licensed role{' '} + + Licensed role is based on a user's Org role (i.e. Viewer, Editor, Admin) and their + dashboard/folder permissions.{' '} + + Learn more + + + } + >
- - {showPaging && } - - )} - - + )} + + Last active  + + + + + + + + + {users.map((user) => ( + + ))} + + + + {showPaging && } + + )} + ); }; +export const UserListAdminPageContent = connector(UserListAdminPageUnConnected); +export function UserListAdminPage() { + return ( + + + + ); +} + const getUsersAriaLabel = (name: string) => { return `Edit user's ${name} details`; }; @@ -349,4 +356,4 @@ const getStyles = (theme: GrafanaTheme2) => { }; }; -export default connector(UserListAdminPageUnConnected); +export default UserListAdminPage; diff --git a/public/app/features/admin/UserListPage.tsx b/public/app/features/admin/UserListPage.tsx new file mode 100644 index 00000000000..fa1cb2ebaeb --- /dev/null +++ b/public/app/features/admin/UserListPage.tsx @@ -0,0 +1,52 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { RadioButtonGroup, Field, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; + +import { Page } from '../../core/components/Page/Page'; +import { AccessControlAction } from '../../types'; +import { UsersListPageContent } from '../users/UsersListPage'; + +import { UserListAdminPageContent } from './UserListAdminPage'; + +const views = [ + { value: 'admin', label: 'All organisations' }, + { value: 'org', label: 'This organisation' }, +]; + +export default function UserListPage() { + const hasAccessToAdminUsers = contextSrv.hasAccess(AccessControlAction.UsersRead, contextSrv.isGrafanaAdmin); + const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); + const styles = useStyles2(getStyles); + const [view, setView] = useState(() => { + if (hasAccessToAdminUsers) { + return 'admin'; + } else if (hasAccessToOrgUsers) { + return 'org'; + } + return null; + }); + + const showToggle = hasAccessToOrgUsers && hasAccessToAdminUsers; + + return ( + + {showToggle && ( + + + + )} + {view === 'admin' ? : } + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + margin: ${theme.spacing(2, 0)}; + `, + }; +}; diff --git a/public/app/features/users/UsersListPage.test.tsx b/public/app/features/users/UsersListPage.test.tsx index cb999f5dd5b..f4e8c2d3b8d 100644 --- a/public/app/features/users/UsersListPage.test.tsx +++ b/public/app/features/users/UsersListPage.test.tsx @@ -6,7 +6,7 @@ import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { configureStore } from 'app/store/configureStore'; import { Invitee, OrgUser } from 'app/types'; -import { Props, UsersListPage } from './UsersListPage'; +import { Props, UsersListPageUnconnected } from './UsersListPage'; import { setUsersSearchPage, setUsersSearchQuery } from './state/reducers'; jest.mock('../../core/app_events', () => ({ @@ -42,7 +42,7 @@ const setup = (propOverrides?: object) => { render( - + ); }; diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 7cab296441d..c30b74326d8 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -48,7 +48,7 @@ export interface State { const pageLimit = 30; -export class UsersListPage extends PureComponent { +export class UsersListPageUnconnected extends PureComponent { declare externalUserMngInfoHtml: string; constructor(props: Props) { @@ -127,19 +127,23 @@ export class UsersListPage extends PureComponent { const externalUserMngInfoHtml = this.externalUserMngInfoHtml; return ( - - - <> - - {externalUserMngInfoHtml && ( -
- )} - {hasFetched && this.renderTable()} - - - + + + {externalUserMngInfoHtml && ( +
+ )} + {hasFetched && this.renderTable()} + ); } } -export default connector(UsersListPage); +export const UsersListPageContent = connector(UsersListPageUnconnected); + +export default function UsersListPage() { + return ( + + + + ); +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index bb3db15c368..37f90b009c4 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -330,9 +330,11 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/admin/users', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage') - ), + component: config.featureToggles.topnav + ? SafeDynamicImport(() => import(/* webpackChunkName: "UserListPage" */ 'app/features/admin/UserListPage')) + : SafeDynamicImport( + () => import(/* webpackChunkName: "UserListAdminPage" */ 'app/features/admin/UserListAdminPage') + ), }, { path: '/admin/users/create', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 99188ce13ff..a5d68e18d78 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -197,8 +197,8 @@ "title": "Organizations" }, "global-users": { - "subtitle": "Manage and create users across the whole Grafana server", - "title": "Users (All orgs)", + "subtitle": "Manage users in Grafana", + "title": "Users", "titleBeforeTopnav": "Users" }, "help": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 7230a71d11d..8d2c31949a2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -21,7 +21,7 @@ "query-tab": "Requête", "stats-tab": "Statistiques", "subtitle": "{{queryCount}} requêtes avec un délai total de requête de {{formatted}}", - "title": "Inspecter : {{panelTitle}}" + "title": "Inspecter\u00a0: {{panelTitle}}" }, "inspect-data": { "data-options": "Options de données", @@ -51,7 +51,7 @@ "panel-json-description": "Le modèle enregistré dans le tableau de bord JSON qui configure comment tout fonctionne.", "panel-json-label": "Panneau JSON", "select-source": "Sélectionner la source", - "unknown": "Objet inconnu : {{show}}" + "unknown": "Objet inconnu\u00a0: {{show}}" }, "inspect-meta": { "no-inspector": "Pas d'inspecteur de métadonnées" @@ -95,7 +95,7 @@ }, "library-panels": { "save": { - "error": "Erreur lors de l'enregistrement du panneau de bibliothèque : \"{{errorMsg}}\"", + "error": "Erreur lors de l'enregistrement du panneau de bibliothèque\u00a0: \"{{errorMsg}}\"", "success": "Panneau de bibliothèque enregistré" } }, @@ -403,7 +403,7 @@ "info-text-1": "Un instantané est un moyen instantané de partager publiquement un tableau de bord interactif. Lors de la création, nous supprimons les données sensibles telles que les requêtes (métrique, modèle et annotation) et les liens du panneau, pour ne laisser que les métriques visibles et les noms de séries intégrés dans votre tableau de bord.", "info-text-2": "N'oubliez pas que votre instantané <1>peut être consulté par une personne qui dispose du lien et qui peut accéder à l'URL. Partagez judicieusement.", "local-button": "Instantané local", - "mistake-message": "Avez-vous commis une erreur ? ", + "mistake-message": "Avez-vous commis une erreur\u00a0? ", "name": "Nom de l'instantané", "timeout": "Délai d’expiration (secondes)", "timeout-description": "Vous devrez peut-être configurer la valeur du délai d'expiration si la collecte des métriques de votre tableau de bord prend beaucoup de temps.", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index abce1243e4c..14a3869cc61 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -197,8 +197,8 @@ "title": "Øřģäʼnįžäŧįőʼnş" }, "global-users": { - "subtitle": "Mäʼnäģę äʼnđ čřęäŧę ūşęřş äčřőşş ŧĥę ŵĥőľę Ğřäƒäʼnä şęřvęř", - "title": "Ůşęřş (Åľľ őřģş)", + "subtitle": "Mäʼnäģę ūşęřş įʼn Ğřäƒäʼnä", + "title": "Ůşęřş", "titleBeforeTopnav": "Ůşęřş" }, "help": { From 0fca3cf9ddc1c0e4dd1407d36099ac630fa5a74f Mon Sep 17 00:00:00 2001 From: Will Browne Date: Wed, 30 Nov 2022 14:25:04 +0100 Subject: [PATCH 036/168] Datasources: Use context logger in cache service (#59547) --- pkg/services/datasources/service/cache.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/datasources/service/cache.go b/pkg/services/datasources/service/cache.go index 3b87f1bdb8f..dff1551101b 100644 --- a/pkg/services/datasources/service/cache.go +++ b/pkg/services/datasources/service/cache.go @@ -49,7 +49,7 @@ func (dc *CacheServiceImpl) GetDatasource( } } - dc.logger.Debug("Querying for data source via SQL store", "id", datasourceID, "orgId", user.OrgID) + dc.logger.FromContext(ctx).Debug("Querying for data source via SQL store", "id", datasourceID, "orgId", user.OrgID) query := &datasources.GetDataSourceQuery{Id: datasourceID, OrgId: user.OrgID} ss := SqlStore{db: dc.SQLStore, logger: dc.logger} @@ -90,7 +90,7 @@ func (dc *CacheServiceImpl) GetDatasourceByUID( } } - dc.logger.Debug("Querying for data source via SQL store", "uid", datasourceUID, "orgId", user.OrgID) + dc.logger.FromContext(ctx).Debug("Querying for data source via SQL store", "uid", datasourceUID, "orgId", user.OrgID) query := &datasources.GetDataSourceQuery{Uid: datasourceUID, OrgId: user.OrgID} ss := SqlStore{db: dc.SQLStore, logger: dc.logger} err := ss.GetDataSource(ctx, query) From d6275c58dde8191682fd050a76cbf3d675835638 Mon Sep 17 00:00:00 2001 From: idafurjes <36131195+idafurjes@users.noreply.github.com> Date: Wed, 30 Nov 2022 14:28:51 +0100 Subject: [PATCH 037/168] Remove GetSignedInUserWithCacheCtx and GetSignedInUser from sqlstore (#59551) * Remove GetSignedInUserWithCacheCtx and GetSignedInUser from sqlstore * Delete removed method from test --- pkg/services/org/orgimpl/store_test.go | 3 - pkg/services/sqlstore/mockstore/mockstore.go | 5 - pkg/services/sqlstore/org_test.go | 14 --- pkg/services/sqlstore/store.go | 1 - pkg/services/sqlstore/user.go | 116 ------------------- pkg/services/user/userimpl/store_test.go | 22 +++- 6 files changed, 18 insertions(+), 143 deletions(-) diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index ecaf5b34297..60461b8698c 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -401,9 +401,6 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { err = orgUserStore.RemoveOrgUser(context.Background(), &remCmd) require.NoError(t, err) require.True(t, remCmd.UserWasDeleted) - - err = ss.GetSignedInUser(context.Background(), &models.GetSignedInUserQuery{UserId: ac2.ID}) - require.Equal(t, err, user.ErrUserNotFound) }) t.Run("Cannot delete last admin org user", func(t *testing.T) { diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 9ce5d0990ea..6f6de30c37a 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -75,11 +75,6 @@ func (m *SQLStoreMock) GetUserProfile(ctx context.Context, query *models.GetUser return m.ExpectedError } -func (m *SQLStoreMock) GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error { - query.Result = m.ExpectedSignedInUser - return m.ExpectedError -} - func (m *SQLStoreMock) CreateTeam(name string, email string, orgID int64) (models.Team, error) { return models.Team{ Name: name, diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index 16099f7a33d..2f79f4e4548 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -78,20 +78,6 @@ func TestIntegrationAccountDataAccess(t *testing.T) { require.NoError(t, err) }) - t.Run("Can get logged in user projection", func(t *testing.T) { - query := models.GetSignedInUserQuery{UserId: ac2.ID} - err := sqlStore.GetSignedInUser(context.Background(), &query) - - require.NoError(t, err) - require.Equal(t, query.Result.Email, "ac2@test.com") - require.Equal(t, query.Result.OrgID, ac2.OrgID) - require.Equal(t, query.Result.Name, "ac2 name") - require.Equal(t, query.Result.Login, "ac2") - require.EqualValues(t, query.Result.OrgRole, "Admin") - require.Equal(t, query.Result.OrgName, "ac2@test.com") - require.Equal(t, query.Result.IsGrafanaAdmin, true) - }) - t.Run("Can get user organizations", func(t *testing.T) { query := models.GetUserOrgListQuery{UserId: ac2.ID} err := sqlStore.GetUserOrgList(context.Background(), &query) diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 16ca3e885aa..0b903c29290 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -20,7 +20,6 @@ type Store interface { GetDBType() core.DbType GetSystemStats(ctx context.Context, query *models.GetSystemStatsQuery) error CreateUser(ctx context.Context, cmd user.CreateUserCommand) (*user.User, error) - GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error WithDbSession(ctx context.Context, callback DBTransactionFunc) error WithNewDbSession(ctx context.Context, callback DBTransactionFunc) error WithTransactionalDbSession(ctx context.Context, callback DBTransactionFunc) error diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 4064ffc696b..a587950f935 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -7,7 +7,6 @@ import ( "sort" "strconv" "strings" - "time" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/models" @@ -207,121 +206,6 @@ func (ss *SQLStore) GetUserOrgList(ctx context.Context, query *models.GetUserOrg }) } -func newSignedInUserCacheKey(orgID, userID int64) string { - return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID) -} - -// deprecated method, use only for tests -func (ss *SQLStore) GetSignedInUserWithCacheCtx(ctx context.Context, query *models.GetSignedInUserQuery) error { - cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId) - if cached, found := ss.CacheService.Get(cacheKey); found { - cachedUser := cached.(user.SignedInUser) - query.Result = &cachedUser - return nil - } - - err := ss.GetSignedInUser(ctx, query) - if err != nil { - return err - } - - cacheKey = newSignedInUserCacheKey(query.Result.OrgID, query.UserId) - ss.CacheService.Set(cacheKey, *query.Result, time.Second*5) - return nil -} - -func (ss *SQLStore) GetSignedInUser(ctx context.Context, query *models.GetSignedInUserQuery) error { - return ss.WithDbSession(ctx, func(dbSess *DBSession) error { - orgId := "u.org_id" - if query.OrgId > 0 { - orgId = strconv.FormatInt(query.OrgId, 10) - } - - var rawSQL = `SELECT - u.id as user_id, - u.is_admin as is_grafana_admin, - u.email as email, - u.login as login, - u.name as name, - u.is_disabled as is_disabled, - u.help_flags1 as help_flags1, - u.last_seen_at as last_seen_at, - (SELECT COUNT(*) FROM org_user where org_user.user_id = u.id) as org_count, - user_auth.auth_module as external_auth_module, - user_auth.auth_id as external_auth_id, - org.name as org_name, - org_user.role as org_role, - org.id as org_id - FROM ` + dialect.Quote("user") + ` as u - LEFT OUTER JOIN user_auth on user_auth.user_id = u.id - LEFT OUTER JOIN org_user on org_user.org_id = ` + orgId + ` and org_user.user_id = u.id - LEFT OUTER JOIN org on org.id = org_user.org_id ` - - sess := dbSess.Table("user") - sess = sess.Context(ctx) - switch { - case query.UserId > 0: - sess.SQL(rawSQL+"WHERE u.id=?", query.UserId) - case query.Login != "": - if ss.Cfg.CaseInsensitiveLogin { - sess.SQL(rawSQL+"WHERE LOWER(u.login)=LOWER(?)", query.Login) - } else { - sess.SQL(rawSQL+"WHERE u.login=?", query.Login) - } - case query.Email != "": - if ss.Cfg.CaseInsensitiveLogin { - sess.SQL(rawSQL+"WHERE LOWER(u.email)=LOWER(?)", query.Email) - } else { - sess.SQL(rawSQL+"WHERE u.email=?", query.Email) - } - } - - var usr user.SignedInUser - has, err := sess.Get(&usr) - if err != nil { - return err - } else if !has { - return user.ErrUserNotFound - } - - if usr.OrgRole == "" { - usr.OrgID = -1 - usr.OrgName = "Org missing" - } - - if usr.ExternalAuthModule != "oauth_grafana_com" { - usr.ExternalAuthID = "" - } - - // tempUser is used to retrieve the teams for the signed in user for internal use. - tempUser := &user.SignedInUser{ - OrgID: usr.OrgID, - Permissions: map[int64]map[string][]string{ - usr.OrgID: { - ac.ActionTeamsRead: {ac.ScopeTeamsAll}, - }, - }, - } - getTeamsByUserQuery := &models.GetTeamsByUserQuery{ - OrgId: usr.OrgID, - UserId: usr.UserID, - SignedInUser: tempUser, - } - err = ss.GetTeamsByUser(ctx, getTeamsByUserQuery) - if err != nil { - return err - } - - usr.Teams = make([]int64, len(getTeamsByUserQuery.Result)) - for i, t := range getTeamsByUserQuery.Result { - usr.Teams[i] = t.Id - } - - query.Result = &usr - return err - }) -} - // GetTeamsByUser is used by the Guardian when checking a users' permissions // TODO: use team.Service after user service is split func (ss *SQLStore) GetTeamsByUser(ctx context.Context, query *models.GetTeamsByUserQuery) error { diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 90c1d1a2aa7..48e2d2de23b 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -454,11 +454,11 @@ func TestIntegrationUserDataAccess(t *testing.T) { ss.CacheService.Flush() - query3 := &models.GetSignedInUserQuery{OrgId: users[1].OrgID, UserId: users[1].ID} - err = ss.GetSignedInUserWithCacheCtx(context.Background(), query3) + query3 := &user.GetSignedInUserQuery{OrgID: users[1].OrgID, UserID: users[1].ID} + query3Result, err := userStore.GetSignedInUser(context.Background(), query3) require.Nil(t, err) - require.NotNil(t, query3.Result) - require.Equal(t, query3.OrgId, users[1].OrgID) + require.NotNil(t, query3Result) + require.Equal(t, query3.OrgID, users[1].OrgID) disableCmd := user.BatchDisableUsersCommand{ UserIDs: []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID, users[4].ID}, @@ -706,6 +706,20 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Len(t, queryResult.Users, 1) require.EqualValues(t, queryResult.TotalCount, 1) }) + + t.Run("Can get logged in user projection", func(t *testing.T) { + query := user.GetSignedInUserQuery{UserID: 2} + queryResult, err := userStore.GetSignedInUser(context.Background(), &query) + + require.NoError(t, err) + assert.Equal(t, queryResult.Email, "user1@test.com") + assert.EqualValues(t, queryResult.OrgID, 2) + assert.Equal(t, queryResult.Name, "user1") + assert.Equal(t, queryResult.Login, "loginuser1") + assert.EqualValues(t, queryResult.OrgRole, "Admin") + assert.Equal(t, queryResult.OrgName, "user1@test.com") + assert.Equal(t, queryResult.IsGrafanaAdmin, false) + }) } func TestIntegrationUserUpdate(t *testing.T) { From 10a83714c87253c2951e97913cc2a2e61801eb41 Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Wed, 30 Nov 2022 10:04:23 -0400 Subject: [PATCH 038/168] Automate docs publishing steps (#59550) Signed-off-by: Jack Baldry Signed-off-by: Jack Baldry --- .../publish-technical-documentation-next.yml | 31 ++++++++++ ...ublish-technical-documentation-release.yml | 61 +++++++++++++++++++ .github/workflows/publish.yml | 47 -------------- 3 files changed, 92 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/publish-technical-documentation-next.yml create mode 100644 .github/workflows/publish-technical-documentation-release.yml delete mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish-technical-documentation-next.yml b/.github/workflows/publish-technical-documentation-next.yml new file mode 100644 index 00000000000..47544cca0d3 --- /dev/null +++ b/.github/workflows/publish-technical-documentation-next.yml @@ -0,0 +1,31 @@ +name: "publish-technical-documentation-next" + +on: + push: + branches: + - "main" + paths: + - "docs/sources/**" + - "packages/grafana-*/**" + workflow_dispatch: +jobs: + sync: + runs-on: "ubuntu-latest" + needs: "test" + steps: + - name: "Checkout Grafana repo" + uses: "actions/checkout@v3" + + - name: "Clone website-sync Action" + run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync" + + - name: "Publish to website repository (next)" + uses: "./.github/actions/website-sync" + id: "publish-next" + with: + repository: "grafana/website" + branch: "master" + host: "github.com" + github_pat: "${{ secrets.GH_BOT_ACCESS_TOKEN }}" + source_folder: "docs/sources" + target_folder: "content/docs/grafana/next" diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml new file mode 100644 index 00000000000..31019306b0f --- /dev/null +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -0,0 +1,61 @@ +name: "publish-technical-documentation-release" + +on: + push: + branches: + - v[0-9]+.[0-9]+.[0-9]+ + tags: + - v[0-9]+.[0-9]+.[0-9]+ + paths: + - "docs/sources/**" + - "packages/grafana-*/**" + workflow_dispatch: +jobs: + sync: + runs-on: "ubuntu-latest" + needs: "test" + steps: + - name: "Checkout Grafana repo" + uses: "actions/checkout@v3" + with: + fetch-depth: 0 + + - name: "Checkout Actions library" + uses: "actions/checkout@v3" + with: + repository: "grafana/grafana-github-actions" + path: "./actions" + + - name: "Install Actions from library" + run: "npm install --production --prefix ./actions" + + - name: "Determine if there is a matching release tag" + id: "has-matching-release-tag" + uses: "./actions/has-matching-release-tag" + with: + ref_name: "${{ github.ref_name }}" + release_tag_regexp: "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" + release_branch_regexp: "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$" + + - name: "Determine technical documentation version" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + uses: "./actions/docs-target" + id: "target" + with: + ref_name: "${{ github.ref_name }}" + + - name: "Clone website-sync Action" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync" + + - name: "Publish to website repository (release)" + if: "steps.has-matching-release-tag.outputs.bool == 'true'" + uses: "./.github/actions/website-sync" + id: "publish-release" + with: + repository: "grafana/website" + branch: "master" + host: "github.com" + github_pat: "${{ secrets.GH_BOT_ACCESS_TOKEN }}" + source_folder: "docs/sources" + target_folder: "content/docs/grafana/${{ steps.target.outputs.target }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 95207729772..00000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: publish_docs - -on: - push: - branches: - - main - paths: - - 'docs/sources/**' - - 'packages/grafana-*/**' - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync - - name: setup node - uses: actions/setup-node@v3.5.1 - with: - node-version: '16' - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3.0.11 - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - yarn- - - run: yarn install --immutable - - name: publish-to-git - uses: ./.github/actions/website-sync - id: publish - with: - repository: grafana/website - branch: master - host: github.com - github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' - source_folder: docs/sources - target_folder: content/docs/grafana/next - allow_no_changes: 'true' - - shell: bash - run: | - test -n "${{ steps.publish.outputs.commit_hash }}" - test -n "${{ steps.publish.outputs.working_directory }}" From fee50be1bb27ef64932980c08ece1037830c65b3 Mon Sep 17 00:00:00 2001 From: Jo Date: Wed, 30 Nov 2022 14:33:19 +0000 Subject: [PATCH 039/168] Sessions: Remove invalid session cookie if it's invalid/expired/missing (#59556) only remove invalid session cookie if it's invalid/expired/missing --- pkg/models/usertoken/user_token.go | 13 ++++++++++++- pkg/services/auth/auth.go | 15 +++++++++++---- pkg/services/contexthandler/contexthandler.go | 9 ++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pkg/models/usertoken/user_token.go b/pkg/models/usertoken/user_token.go index beb2ac1355f..9b350f300b7 100644 --- a/pkg/models/usertoken/user_token.go +++ b/pkg/models/usertoken/user_token.go @@ -1,12 +1,23 @@ package usertoken +import ( + "errors" + "fmt" +) + +var ErrInvalidSessionToken = errors.New("invalid session token") + type TokenRevokedError struct { UserID int64 TokenID int64 MaxConcurrentSessions int64 } -func (e *TokenRevokedError) Error() string { return "user token revoked" } +func (e *TokenRevokedError) Error() string { + return fmt.Sprintf("%s: user token revoked", ErrInvalidSessionToken) +} + +func (e *TokenRevokedError) Unwrap() error { return ErrInvalidSessionToken } // UserToken represents a user token type UserToken struct { diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index 28d17307047..3d1f381ea80 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "fmt" "net" "github.com/grafana/grafana/pkg/models/usertoken" @@ -18,10 +19,14 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken ) -type TokenRevokedError = usertoken.TokenRevokedError +type ( + TokenRevokedError = usertoken.TokenRevokedError + UserToken = usertoken.UserToken +) // CreateTokenErr represents a token creation error; used in Enterprise type CreateTokenErr struct { @@ -42,9 +47,11 @@ type TokenExpiredError struct { TokenID int64 } -func (e *TokenExpiredError) Error() string { return "user token expired" } +func (e *TokenExpiredError) Unwrap() error { return ErrInvalidSessionToken } -type UserToken = usertoken.UserToken +func (e *TokenExpiredError) Error() string { + return fmt.Sprintf("%s: user token expired", ErrInvalidSessionToken) +} type RevokeAuthTokenCmd struct { AuthTokenId int64 `json:"authTokenId"` diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index f09015707d3..0b2c3de850e 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -429,9 +429,12 @@ func (h *ContextHandler) initContextWithToken(reqContext *models.ReqContext, org token, err := h.AuthTokenService.LookupToken(ctx, rawToken) if err != nil { - reqContext.Logger.Warn("Failed to look up user based on cookie", "error", err) - // Burn the cookie in case of failure - reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + reqContext.Logger.Warn("failed to look up session from cookie", "error", err) + if errors.Is(err, auth.ErrUserTokenNotFound) || errors.Is(err, auth.ErrInvalidSessionToken) { + // Burn the cookie in case of invalid, expired or missing token + reqContext.Resp.Before(h.deleteInvalidCookieEndOfRequestFunc(reqContext)) + } + reqContext.LookupTokenErr = err return false From bf49c20050828900d7938b5055922fed19a39039 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 30 Nov 2022 15:38:49 +0100 Subject: [PATCH 040/168] RBAC: Add an endpoint to list all user permissions (#57644) * RBAC: Add an endpoint to see all user permissions Co-authored-by: Joey Orlando * Fix mock * Add feature flag * Fix merging * Return normal permissions instead of simplified ones * Fix test * Fix tests * Fix tests * Create benchtests * Split function to get basic roles * Comments * Reorg * Add two more tests to the bench * bench comment * Re-ran the test * Rename GetUsersPermissions to SearchUsersPermissions and prepare search options * Remove from model unused struct * Start adding option to get permissions by Action+Scope * Wrong import * Action and Scope * slightly tweak users permissions actionPrefix query param validation logic * Fix xor check * Lint * Account for suggeston Co-authored-by: ievaVasiljeva * Add search * Remove comment on global scope * use union all and update test to make it run on all dbs * Fix MySQL needs a space * Account for suggestion. Co-authored-by: ievaVasiljeva Co-authored-by: Joey Orlando Co-authored-by: Joey Orlando Co-authored-by: ievaVasiljeva --- pkg/api/common_test.go | 4 +- pkg/api/org_users_test.go | 9 +- pkg/services/accesscontrol/accesscontrol.go | 8 + pkg/services/accesscontrol/acimpl/service.go | 95 ++++- .../acimpl/service_bench_test.go | 210 +++++++++++ .../accesscontrol/acimpl/service_test.go | 150 ++++++++ pkg/services/accesscontrol/actest/fake.go | 34 +- pkg/services/accesscontrol/api/api.go | 40 +- pkg/services/accesscontrol/api/api_test.go | 5 +- .../accesscontrol/database/database.go | 104 ++++++ .../accesscontrol/database/database_test.go | 349 +++++++++++++++++- pkg/services/accesscontrol/mock/mock.go | 12 + pkg/services/accesscontrol/models.go | 1 + pkg/services/accesscontrol/roles.go | 4 + 14 files changed, 1003 insertions(+), 22 deletions(-) create mode 100644 pkg/services/accesscontrol/acimpl/service_bench_test.go diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 67230445046..c9450f7267a 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -404,11 +404,11 @@ func setupHTTPServerWithCfgDb( userSvc = userMock } else { var err error - acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) - require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) require.NoError(t, err) + acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), ac, featuremgmt.WithFeatures()) + require.NoError(t, err) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 291d2c3988d..7a418591664 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -377,10 +377,11 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { enableAccessControl: true, expectedCode: http.StatusOK, expectedMetadata: map[string]bool{ - "org.users:write": true, - "org.users:add": true, - "org.users:read": true, - "org.users:remove": true}, + "org.users:write": true, + "org.users:add": true, + "org.users:read": true, + "org.users:remove": true, + "users.permissions:read": true}, user: testServerAdminViewer, targetOrg: testServerAdminViewer.OrgID, }, diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index ecbdf55692d..965cd622249 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -26,6 +26,8 @@ type Service interface { registry.ProvidesUsageStats // GetUserPermissions returns user permissions with only action and scope fields set. GetUserPermissions(ctx context.Context, user *user.SignedInUser, options Options) ([]Permission, error) + // SearchUsersPermissions returns all users' permissions filtered by an action prefix + SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options SearchOptions) (map[int64][]Permission, error) // ClearUserPermissionCache removes the permission cache entry for the given user ClearUserPermissionCache(user *user.SignedInUser) // DeleteUserPermissions removes all permissions user has in org and all permission to that user @@ -47,6 +49,12 @@ type Options struct { ReloadCache bool } +type SearchOptions struct { + ActionPrefix string // Needed for the PoC v1, it's probably going to be removed. + Action string + Scope string +} + type TeamPermissionsService interface { GetPermissions(ctx context.Context, user *user.SignedInUser, resourceID string) ([]ResourcePermission, error) SetUserPermission(ctx context.Context, orgID int64, user User, resourceID, permission string) (*ResourcePermission, error) diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index b5ac20d5ad1..d5d479c64ac 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -3,6 +3,8 @@ package acimpl import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/prometheus/client_golang/prometheus" @@ -30,11 +32,11 @@ const ( ) func ProvideService(cfg *setting.Cfg, store db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, - features *featuremgmt.FeatureManager) (*Service, error) { + accessControl accesscontrol.AccessControl, features *featuremgmt.FeatureManager) (*Service, error) { service := ProvideOSSService(cfg, database.ProvideService(store), cache, features) if !accesscontrol.IsDisabled(cfg) { - api.NewAccessControlAPI(routeRegister, service).RegisterAPIEndpoints() + api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service); err != nil { return nil, err } @@ -58,6 +60,8 @@ func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheSer type store interface { GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) + SearchUsersPermissions(ctx context.Context, orgID int64, option accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) + GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error } @@ -244,3 +248,90 @@ func (s *Service) DeclarePluginRoles(_ context.Context, ID, name string, regs [] return nil } + +// SearchUsersPermissions returns all users' permissions filtered by action prefixes +func (s *Service) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, + options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + // Filter ram permissions + basicPermissions := map[string][]accesscontrol.Permission{} + for role, basicRole := range s.roles { + for i := range basicRole.Permissions { + if options.ActionPrefix != "" { + if strings.HasPrefix(basicRole.Permissions[i].Action, options.ActionPrefix) { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + if options.Action != "" { + if basicRole.Permissions[i].Action == options.Action { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + } + } + + usersRoles, err := s.store.GetUsersBasicRoles(ctx, orgID) + if err != nil { + return nil, err + } + + // Get managed permissions (DB) + usersPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, options) + if err != nil { + return nil, err + } + + // helper to filter out permissions the signed in users cannot see + canView := func() func(userID int64) bool { + siuPermissions, ok := user.Permissions[orgID] + if !ok { + return func(_ int64) bool { return false } + } + scopes, ok := siuPermissions[accesscontrol.ActionUsersPermissionsRead] + if !ok { + return func(_ int64) bool { return false } + } + + ids := map[int64]bool{} + for i := range scopes { + if strings.HasSuffix(scopes[i], "*") { + return func(_ int64) bool { return true } + } + parts := strings.Split(scopes[i], ":") + if len(parts) != 3 { + continue + } + id, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + continue + } + ids[id] = true + } + + return func(userID int64) bool { return ids[userID] } + }() + + // Merge stored (DB) and basic role permissions (RAM) + // Assumes that all users with stored permissions have org roles + res := map[int64][]accesscontrol.Permission{} + for userID, roles := range usersRoles { + if !canView(userID) { + continue + } + perms := []accesscontrol.Permission{} + for i := range roles { + basicPermission, ok := basicPermissions[roles[i]] + if !ok { + continue + } + perms = append(perms, basicPermission...) + } + if dbPerms, ok := usersPermissions[userID]; ok { + perms = append(perms, dbPerms...) + } + if len(perms) > 0 { + res[userID] = perms + } + } + + return res, nil +} diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go new file mode 100644 index 00000000000..df6660bebe0 --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -0,0 +1,210 @@ +package acimpl + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +const batchSize = 500 + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + + if err := eachFn(i, end); err != nil { + return err + } + + i = end + } + + return nil +} + +func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.Service, *user.SignedInUser) { + now := time.Now() + sqlStore := db.InitTestDB(b) + store := database.ProvideService(sqlStore) + acService := &Service{ + cfg: setting.NewCfg(), + log: log.New("accesscontrol-test"), + registrations: accesscontrol.RegistrationList{}, + store: store, + roles: accesscontrol.BuildBasicRoleDefinitions(), + } + + // Prepare default permissions + action1 := "resources:action1" + err := acService.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{Name: "fixed:test:role", Permissions: []accesscontrol.Permission{{Action: action1}}}, + Grants: []string{string(org.RoleViewer)}, + }) + require.NoError(b, err) + err = acService.RegisterFixedRoles(context.Background()) + require.NoError(b, err) + + // Prepare managed permissions + action2 := "resources:action2" + users := make([]user.User, 0, usersCount) + orgUsers := make([]org.OrgUser, 0, usersCount) + roles := make([]accesscontrol.Role, 0, usersCount) + userRoles := make([]accesscontrol.UserRole, 0, usersCount) + permissions := make([]accesscontrol.Permission, 0, resourceCount*usersCount) + for u := 1; u < usersCount+1; u++ { + users = append(users, user.User{ + ID: int64(u), + Name: fmt.Sprintf("user%v", u), + Login: fmt.Sprintf("user%v", u), + Email: fmt.Sprintf("user%v@example.org", u), + Created: now, + Updated: now, + }) + orgUsers = append(orgUsers, org.OrgUser{ + ID: int64(u), + UserID: int64(u), + OrgID: 1, + Role: org.RoleViewer, + Created: now, + Updated: now, + }) + roles = append(roles, accesscontrol.Role{ + ID: int64(u), + UID: fmt.Sprintf("managed_users_%v_permissions", u), + Name: fmt.Sprintf("managed:users:%v:permissions", u), + Version: 1, + Created: now, + Updated: now, + }) + userRoles = append(userRoles, accesscontrol.UserRole{ + ID: int64(u), + OrgID: 1, + RoleID: int64(u), + UserID: int64(u), + Created: now, + }) + + for r := 1; r < resourceCount+1; r++ { + permissions = append(permissions, accesscontrol.Permission{ + RoleID: int64(u), + Action: action2, + Scope: fmt.Sprintf("resources:id:%v", r), + Created: now, + Updated: now, + }) + } + } + + // Populate store + if err := batch(len(roles), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + if _, err := sess.Insert(users[start:end]); err != nil { + return err + } + if _, err := sess.Insert(orgUsers[start:end]); err != nil { + return err + } + if _, err := sess.Insert(roles[start:end]); err != nil { + return err + } + _, err := sess.Insert(userRoles[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert users and roles") + return nil, nil + } + if err := batch(len(permissions), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Insert(permissions[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert permissions") + return nil, nil + } + + // Allow signed in user to view all users permissions in the worst way + userPermissions := map[string][]string{} + for u := 1; u < usersCount+1; u++ { + userPermissions[accesscontrol.ActionUsersPermissionsRead] = + append(userPermissions[accesscontrol.ActionUsersPermissionsRead], fmt.Sprintf("users:id:%v", u)) + } + return acService, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: userPermissions}} +} + +func benchSearchUsersPermissions(b *testing.B, usersCount, resourceCount int) { + acService, siu := setupBenchEnv(b, usersCount, resourceCount) + b.ResetTimer() + + for n := 0; n < b.N; n++ { + usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, 1, accesscontrol.SearchOptions{ActionPrefix: "resources:"}) + require.NoError(b, err) + require.Len(b, usersPermissions, usersCount) + for _, permissions := range usersPermissions { + // action1 on all resource + action2 + require.Len(b, permissions, resourceCount+1) + } + } +} + +// Lots of resources +func BenchmarkSearchUsersPermissions_10_1K(b *testing.B) { benchSearchUsersPermissions(b, 10, 1000) } // ~0.047s/op +func BenchmarkSearchUsersPermissions_10_10K(b *testing.B) { benchSearchUsersPermissions(b, 10, 10000) } // ~0.5s/op +func BenchmarkSearchUsersPermissions_10_100K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 100000) +} // ~4.6s/op +func BenchmarkSearchUsersPermissions_10_1M(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 1000000) +} // ~55.36s/op + +// Lots of users (most probable case) +func BenchmarkSearchUsersPermissions_1K_10(b *testing.B) { benchSearchUsersPermissions(b, 1000, 10) } // ~0.056s/op +func BenchmarkSearchUsersPermissions_10K_10(b *testing.B) { benchSearchUsersPermissions(b, 10000, 10) } // ~0.58s/op +func BenchmarkSearchUsersPermissions_100K_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 100000, 10) +} // ~6.21s/op +func BenchmarkSearchUsersPermissions_1M_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 1000000, 10) +} // ~57s/op + +// Lots of both +func BenchmarkSearchUsersPermissions_10K_100(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 100) +} // ~1.45s/op +func BenchmarkSearchUsersPermissions_10K_1K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 1000) +} // ~50s/op diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 09bd7435afa..fd9d6bb24a8 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -12,8 +12,10 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" @@ -65,6 +67,7 @@ func TestUsageMetrics(t *testing.T) { db.InitTestDB(t), routing.NewRouteRegister(), localcache.ProvideService(), + actest.FakeAccessControl{}, featuremgmt.WithFeatures(), ) require.NoError(t, errInitAc) @@ -373,6 +376,153 @@ func TestService_RegisterFixedRoles(t *testing.T) { } } +func TestService_SearchUsersPermissions(t *testing.T) { + searchOption := accesscontrol.SearchOptions{ActionPrefix: "teams"} + ctx := context.Background() + listAllPerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:*"}} + listSomePerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:id:2"}} + tests := []struct { + name string + siuPermissions map[string][]string + ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole + storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions + storedRoles map[int64][]string // UserID => Roles + want map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "ram only", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "stored only", + siuPermissions: listAllPerms, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "ram and stored", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "view permission on subset of users only", + siuPermissions: listSomePerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "check action filter on RAM permissions works correctly", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersCreate}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{1: {accesscontrol.RoleGrafanaAdmin}}, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac := setupTestEnv(t) + + ac.roles = tt.ramRoles + ac.store = actest.FakeStore{ + ExpectedUsersPermissions: tt.storedPerms, + ExpectedUsersRoles: tt.storedRoles, + } + + siu := &user.SignedInUser{OrgID: 2, Permissions: map[int64]map[string][]string{2: tt.siuPermissions}} + got, err := ac.SearchUsersPermissions(ctx, siu, 2, searchOption) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + + require.Len(t, got, len(tt.want), "expected more users permissions") + for userID, wantPerm := range tt.want { + gotPerm, ok := got[userID] + require.True(t, ok, "expected permissions for user", userID) + + require.ElementsMatch(t, gotPerm, wantPerm) + } + }) + } +} + func TestPermissionCacheKey(t *testing.T) { testcases := []struct { name string diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index d62f7a9424b..3f6bad09e01 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -11,9 +11,10 @@ var _ accesscontrol.Service = new(FakeService) var _ accesscontrol.RoleRegistry = new(FakeService) type FakeService struct { - ExpectedErr error - ExpectedDisabled bool - ExpectedPermissions []accesscontrol.Permission + ExpectedErr error + ExpectedDisabled bool + ExpectedPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission } func (f FakeService) GetUsageStats(ctx context.Context) map[string]interface{} { @@ -24,6 +25,10 @@ func (f FakeService) GetUserPermissions(ctx context.Context, user *user.SignedIn return f.ExpectedPermissions, f.ExpectedErr } +func (f FakeService) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + func (f FakeService) ClearUserPermissionCache(user *user.SignedInUser) {} func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { @@ -60,3 +65,26 @@ func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolve func (f FakeAccessControl) IsDisabled() bool { return f.ExpectedDisabled } + +type FakeStore struct { + ExpectedUserPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission + ExpectedUsersRoles map[int64][]string + ExpectedErr error +} + +func (f FakeStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { + return f.ExpectedUserPermissions, f.ExpectedErr +} + +func (f FakeStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + +func (f FakeStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + return f.ExpectedUsersRoles, f.ExpectedErr +} + +func (f FakeStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { + return f.ExpectedErr +} diff --git a/pkg/services/accesscontrol/api/api.go b/pkg/services/accesscontrol/api/api.go index 04e90e276c4..5c06a9ef239 100644 --- a/pkg/services/accesscontrol/api/api.go +++ b/pkg/services/accesscontrol/api/api.go @@ -8,25 +8,36 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -func NewAccessControlAPI(router routing.RouteRegister, service ac.Service) *AccessControlAPI { +func NewAccessControlAPI(router routing.RouteRegister, accesscontrol ac.AccessControl, service ac.Service, + features *featuremgmt.FeatureManager) *AccessControlAPI { return &AccessControlAPI{ RouteRegister: router, Service: service, + AccessControl: accesscontrol, + features: features, } } type AccessControlAPI struct { Service ac.Service + AccessControl ac.AccessControl RouteRegister routing.RouteRegister + features *featuremgmt.FeatureManager } func (api *AccessControlAPI) RegisterAPIEndpoints() { + authorize := ac.Middleware(api.AccessControl) // Users api.RouteRegister.Group("/api/access-control", func(rr routing.RouteRegister) { rr.Get("/user/actions", middleware.ReqSignedIn, routing.Wrap(api.getUserActions)) rr.Get("/user/permissions", middleware.ReqSignedIn, routing.Wrap(api.getUserPermissions)) + if api.features.IsEnabled(featuremgmt.FlagAccessControlOnCall) { + rr.Get("/users/permissions/search", authorize(middleware.ReqSignedIn, + ac.EvalPermission(ac.ActionUsersPermissionsRead)), routing.Wrap(api.SearchUsersPermissions)) + } }) } @@ -53,3 +64,30 @@ func (api *AccessControlAPI) getUserPermissions(c *models.ReqContext) response.R return response.JSON(http.StatusOK, ac.GroupScopesByAction(permissions)) } + +// GET /api/access-control/users/permissions +func (api *AccessControlAPI) SearchUsersPermissions(c *models.ReqContext) response.Response { + searchOptions := ac.SearchOptions{ + ActionPrefix: c.Query("actionPrefix"), + Action: c.Query("action"), + Scope: c.Query("scope"), + } + + // Validate inputs + if (searchOptions.ActionPrefix != "") == (searchOptions.Action != "") { + return response.JSON(http.StatusBadRequest, "provide one of 'action' or 'actionPrefix'") + } + + // Compute metadata + permissions, err := api.Service.SearchUsersPermissions(c.Req.Context(), c.SignedInUser, c.OrgID, searchOptions) + if err != nil { + return response.Error(http.StatusInternalServerError, "could not get org user permissions", err) + } + + permsByAction := map[int64]map[string][]string{} + for userID, userPerms := range permissions { + permsByAction[userID] = ac.GroupScopesByAction(userPerms) + } + + return response.JSON(http.StatusOK, permsByAction) +} diff --git a/pkg/services/accesscontrol/api/api_test.go b/pkg/services/accesscontrol/api/api_test.go index 3be8ee82c30..13a2f6161fc 100644 --- a/pkg/services/accesscontrol/api/api_test.go +++ b/pkg/services/accesscontrol/api/api_test.go @@ -9,6 +9,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web/webtest" @@ -38,7 +39,7 @@ func TestAPI_getUserActions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) @@ -91,7 +92,7 @@ func TestAPI_getUserPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index a53674c9bd5..eed62580b2b 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -55,6 +55,110 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces return result, err } +// SearchUsersPermissions returns the list of user permissions indexed by UserID +func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + type UserRBACPermission struct { + UserID int64 `xorm:"user_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + dbPerms := make([]UserRBACPermission, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find permissions + q := ` + SELECT + user_id, + action, + scope + FROM ( + SELECT ur.user_id, ur.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN user_role AS ur on ur.role_id = p.role_id + UNION ALL + SELECT tm.user_id, tr.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN team_role AS tr ON tr.role_id = p.role_id + INNER JOIN team_member AS tm ON tm.team_id = tr.team_id + UNION ALL + SELECT ou.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN org_user AS ou ON ou.role = br.role + UNION ALL + SELECT sa.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN ( + SELECT u.id AS user_id + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u WHERE u.is_admin + ) AS sa ON 1 = 1 + WHERE br.role = ? + ) AS up + WHERE (org_id = ? OR org_id = ?) + ` + params := []interface{}{accesscontrol.RoleGrafanaAdmin, accesscontrol.GlobalOrgID, orgID} + + if options.ActionPrefix != "" { + q += ` AND action LIKE ?` + params = append(params, options.ActionPrefix+"%") + } + if options.Action != "" { + q += ` AND action = ?` + params = append(params, options.Action) + } + if options.Scope != "" { + q += ` AND scope = ?` + params = append(params, options.Scope) + } + + return sess.SQL(q, params...). + Find(&dbPerms) + }); err != nil { + return nil, err + } + + mapped := map[int64][]accesscontrol.Permission{} + for i := range dbPerms { + mapped[dbPerms[i].UserID] = append(mapped[dbPerms[i].UserID], accesscontrol.Permission{Action: dbPerms[i].Action, Scope: dbPerms[i].Scope}) + } + + return mapped, nil +} + +// GetUsersBasicRoles returns the list of user basic roles (Admin, Editor, Viewer, Grafana Admin) indexed by UserID +func (s *AccessControlStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + type UserOrgRole struct { + UserID int64 `xorm:"id"` + OrgRole string `xorm:"role"` + IsAdmin bool `xorm:"is_admin"` + } + dbRoles := make([]UserOrgRole, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find roles + q := ` + SELECT u.id, ou.role, u.is_admin + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u + LEFT JOIN org_user AS ou ON u.id = ou.user_id + WHERE u.is_admin OR ou.org_id = ? + ` + + return sess.SQL(q, orgID).Find(&dbRoles) + }); err != nil { + return nil, err + } + + roles := map[int64][]string{} + for i := range dbRoles { + if dbRoles[i].OrgRole != "" { + roles[dbRoles[i].UserID] = []string{dbRoles[i].OrgRole} + } + if dbRoles[i].IsAdmin { + roles[dbRoles[i].UserID] = append(roles[dbRoles[i].UserID], accesscontrol.RoleGrafanaAdmin) + } + } + return roles, nil +} + func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM user_role WHERE user_id = ?" diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 8ea2c933d82..57c482c0f91 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -2,20 +2,24 @@ package database import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" rs "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) type getUserPermissionsTestCase struct { @@ -82,7 +86,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - store, permissionStore, sql, teamSvc := setupTestEnv(t) + store, permissionStore, sql, teamSvc, _ := setupTestEnv(t) user, team := createUserAndTeam(t, sql, teamSvc, tt.orgID) @@ -145,7 +149,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { t.Run("expect permissions in all orgs to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -185,7 +189,7 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) t.Run("expect permissions in org 1 to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -225,10 +229,10 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) } -func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Service, orgID int64) (*user.User, models.Team) { +func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, orgID int64) (*user.User, models.Team) { t.Helper() - user, err := sql.CreateUser(context.Background(), user.CreateUserCommand{ + user, err := userSrv.Create(context.Background(), &user.CreateUserCommand{ Login: "user", OrgID: orgID, }) @@ -243,10 +247,339 @@ func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Servic return user, team } -func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, *sqlstore.SQLStore, team.Service) { +type helperServices struct { + userSvc user.Service + teamSvc team.Service + orgSvc org.Service +} + +type testUser struct { + orgRole org.RoleType + isAdmin bool +} + +type dbUser struct { + userID int64 + teamID int64 +} + +func createUsersAndTeams(t *testing.T, svcs helperServices, orgID int64, users []testUser) []dbUser { + t.Helper() + res := []dbUser{} + + for i := range users { + user, err := svcs.userSvc.Create(context.Background(), &user.CreateUserCommand{ + Login: fmt.Sprintf("user%v", i+1), + OrgID: orgID, + IsAdmin: users[i].isAdmin, + }) + require.NoError(t, err) + + // User is not member of the org + if users[i].orgRole == "" { + err = svcs.orgSvc.RemoveOrgUser(context.Background(), + &org.RemoveOrgUserCommand{OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID}) + continue + } + + team, err := svcs.teamSvc.CreateTeam(fmt.Sprintf("team%v", i+1), "", orgID) + require.NoError(t, err) + + err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.Id, false, models.PERMISSION_VIEW) + require.NoError(t, err) + + err = svcs.orgSvc.UpdateOrgUser(context.Background(), + &org.UpdateOrgUserCommand{Role: users[i].orgRole, OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID, teamID: team.Id}) + } + + return res +} + +func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, user.Service, team.Service, org.Service) { sql, cfg := db.InitTestDBwithCfg(t) acstore := ProvideService(sql) permissionStore := rs.NewStore(sql) teamService := teamimpl.ProvideService(sql, cfg) - return acstore, permissionStore, sql, teamService + orgService, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userService, err := userimpl.ProvideService(sql, orgService, cfg, teamService, localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) + return acstore, permissionStore, userService, teamService, orgService +} + +func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { + ctx := context.Background() + readTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + writeTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read", "teams:write"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + readDashPerm := func(dashUID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"dashboards:read"}, + Resource: "dashboards", + ResourceAttribute: "uid", + ResourceID: dashUID, + } + } + tests := []struct { + name string + users []testUser + permCmds []rs.SetResourcePermissionsCommand + options accesscontrol.SearchOptions + wantPerm map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "user assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "users assignment by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:write", Scope: "teams:id:1"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "team assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{{TeamID: 1, SetResourcePermissionCommand: readTeamPerm("1")}}, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "basic role assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "server admin assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "all assignments by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: true}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 2, SetResourcePermissionCommand: readTeamPerm("20")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleEditor), SetResourcePermissionCommand: readTeamPerm("200")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}, {Action: "teams:read", Scope: "teams:id:20"}, + {Action: "teams:read", Scope: "teams:id:200"}}, + }, + }, + { + name: "filter permissions by action prefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readDashPerm("d1")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 1, SetResourcePermissionCommand: readDashPerm("d10")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readDashPerm("d100")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readDashPerm("d1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + }, + }, + { + name: "include not org member server admin permissions by actionPrefix", + // Three users, one member, one not member but Server Admin, one not member and not server admin + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + permCmds: []rs.SetResourcePermissionsCommand{{BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}}, + wantPerm: map[int64][]accesscontrol.Permission{ + 2: {{Action: "teams:read", Scope: "teams:id:1"}}, + }, + }, + { + name: "user assignment by action", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "user assignment by scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:write", Scope: "teams:id:1"}, + }}, + }, + { + name: "user assignment by action and scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read", Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, permissionsStore, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Switch userID and TeamID by the real stored ones + for i := range tt.permCmds { + if tt.permCmds[i].User.ID != 0 { + tt.permCmds[i].User.ID = dbUsers[tt.permCmds[i].User.ID-1].userID + } + if tt.permCmds[i].TeamID != 0 { + tt.permCmds[i].TeamID = dbUsers[tt.permCmds[i].TeamID-1].teamID + } + } + _, err := permissionsStore.SetResourcePermissions(ctx, 1, tt.permCmds, rs.ResourceHooks{}) + require.NoError(t, err) + + // Test + dbPermissions, err := acStore.SearchUsersPermissions(ctx, 1, tt.options) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbPermissions, len(tt.wantPerm)) + + for userID, expectedUserPerms := range tt.wantPerm { + dbUserPerms, ok := dbPermissions[dbUsers[userID-1].userID] + require.True(t, ok, "expected permissions for user", userID) + require.ElementsMatch(t, expectedUserPerms, dbUserPerms) + } + }) + } +} + +func TestAccessControlStore_GetUsersBasicRoles(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + users []testUser + wantRoles map[int64][]string + wantErr bool + }{ + { + name: "user with basic role", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + wantRoles: map[int64][]string{1: {string(org.RoleAdmin)}}, + }, + { + name: "one admin, one editor", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {string(org.RoleEditor)}, + }, + }, + { + name: "one org member, one not member but Server Admin, one not member and not server admin", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, _, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Test + dbRoles, err := acStore.GetUsersBasicRoles(ctx, 1) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbRoles, len(tt.wantRoles)) + + for userID, expectedUserRoles := range tt.wantRoles { + dbUserRoles, ok := dbRoles[dbUsers[userID-1].userID] + require.True(t, ok, "expected organization role for user", userID) + require.ElementsMatch(t, expectedUserRoles, dbUserRoles) + } + }) + } } diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 663902e95ca..c1f3e944b2f 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -28,6 +28,7 @@ type Calls struct { RegisterFixedRoles []interface{} RegisterAttributeScopeResolver []interface{} DeleteUserPermissions []interface{} + SearchUsersPermissions []interface{} } type Mock struct { @@ -52,6 +53,7 @@ type Mock struct { RegisterFixedRolesFunc func() error RegisterScopeAttributeResolverFunc func(string, accesscontrol.ScopeAttributeResolver) DeleteUserPermissionsFunc func(context.Context, int64) error + SearchUsersPermissionsFunc func(context.Context, *user.SignedInUser, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) scopeResolvers accesscontrol.Resolvers } @@ -212,3 +214,13 @@ func (m *Mock) DeleteUserPermissions(ctx context.Context, orgID, userID int64) e } return nil } + +// GetSimplifiedUsersPermissions returns all users' permissions filtered by an action prefix +func (m *Mock) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + m.Calls.SearchUsersPermissions = append(m.Calls.SearchUsersPermissions, []interface{}{ctx, user, orgID, options}) + // Use override if provided + if m.SearchUsersPermissionsFunc != nil { + return m.SearchUsersPermissionsFunc(ctx, user, orgID, options) + } + return nil, nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 143e9f8845a..e32bb26a2b7 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -309,6 +309,7 @@ const ( ActionUsersLogout = "users:logout" ActionUsersQuotasList = "users.quotas:read" ActionUsersQuotasUpdate = "users.quotas:write" + ActionUsersPermissionsRead = "users.permissions:read" // Org actions ActionOrgsRead = "orgs:read" diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index e906f9f9c11..67beeb00fb3 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -71,6 +71,10 @@ var ( Action: ActionOrgUsersRead, Scope: ScopeUsersAll, }, + { + Action: ActionUsersPermissionsRead, + Scope: ScopeUsersAll, + }, }, } From d4d4e05bcb80b420f89c510659ff19a3b4d014b6 Mon Sep 17 00:00:00 2001 From: Robby Milo Date: Wed, 30 Nov 2022 16:06:11 +0100 Subject: [PATCH 041/168] remove `_build` param from whatsnew (#59236) * remove `_build` param from whatsnew * Update whats-new-in-v9-3.md --- docs/sources/whatsnew/whats-new-in-v9-2.md | 2 -- docs/sources/whatsnew/whats-new-in-v9-3.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/sources/whatsnew/whats-new-in-v9-2.md b/docs/sources/whatsnew/whats-new-in-v9-2.md index baaf519f9fb..d90c9b5a246 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-2.md +++ b/docs/sources/whatsnew/whats-new-in-v9-2.md @@ -1,6 +1,4 @@ --- -_build: - list: false aliases: - /docs/grafana/latest/guides/whats-new-in-v9-2/ description: Feature and improvement highlights for Grafana v9.2 diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md index 4ac3fc1be94..3e8d030f8ce 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-3.md +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -1,6 +1,4 @@ --- -_build: - list: false aliases: - /docs/grafana/latest/guides/whats-new-in-v9-3/ description: Feature and improvement highlights for Grafana v9.3 From 6625147e74923a729a32c8e8e7f2a79ebe1c54b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 30 Nov 2022 16:06:32 +0100 Subject: [PATCH 042/168] PanelEdit: Fixes alignment issue with collapse button (#59414) * PanelEdit: Fixes alignment issue with collapse button * Fix --- .../unified/components/CollapseToggle.tsx | 2 +- .../components/alert-groups/AlertGroup.tsx | 1 + .../unified/components/rules/RulesGroup.tsx | 1 + .../PanelEditor/OptionsPaneCategory.tsx | 20 +++++++++++++++---- .../plugins/panel/alertGroups/AlertGroup.tsx | 1 + 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/public/app/features/alerting/unified/components/CollapseToggle.tsx b/public/app/features/alerting/unified/components/CollapseToggle.tsx index d5ae0433b00..9ea400226e6 100644 --- a/public/app/features/alerting/unified/components/CollapseToggle.tsx +++ b/public/app/features/alerting/unified/components/CollapseToggle.tsx @@ -29,6 +29,7 @@ export const CollapseToggle: FC = ({ )} diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx index ca3ce045036..454dbba3e2e 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx @@ -36,6 +36,7 @@ export class PanelEditorQueries extends PureComponent { queries: panel.targets, maxDataPoints: panel.maxDataPoints, minInterval: panel.interval, + savedQueryUid: panel.savedQueryLink?.ref.uid ?? null, // Used by experimental feature queryLibrary timeRange: { from: panel.timeFrom, shift: panel.timeShift, diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index ebf120deb6b..da359a94c3f 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -21,6 +21,7 @@ import { getTemplateSrv, RefreshEvent } from '@grafana/runtime'; import config from 'app/core/config'; import { safeStringifyValue } from 'app/core/utils/explore'; import { getNextRefIdChar } from 'app/core/utils/query'; +import { SavedQueryLink } from 'app/features/query-library/types'; import { QueryGroupOptions } from 'app/types'; import { PanelOptionsChangedEvent, @@ -131,6 +132,7 @@ const defaults: any = { overrides: [], }, title: '', + savedQueryLink: null, }; export class PanelModel implements DataConfigSource, IPanelModel { @@ -155,6 +157,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { datasource: DataSourceRef | null = null; thresholds?: any; pluginVersion?: string; + savedQueryLink: SavedQueryLink | null = null; // Used by the experimental feature queryLibrary snapshotData?: DataFrameDTO[]; timeFrom?: any; @@ -514,6 +517,18 @@ export class PanelModel implements DataConfigSource, IPanelModel { uid: dataSource.uid, type: dataSource.type, }; + + if (options.savedQueryUid) { + this.savedQueryLink = { + ref: { + uid: options.savedQueryUid, + }, + variables: [], + }; + } else { + this.savedQueryLink = null; + } + this.cacheTimeout = options.cacheTimeout; this.timeFrom = options.timeRange?.from; this.timeShift = options.timeRange?.shift; diff --git a/public/app/features/query-library/api/SavedQueriesApi.ts b/public/app/features/query-library/api/SavedQueriesApi.ts new file mode 100644 index 00000000000..54544a65f9e --- /dev/null +++ b/public/app/features/query-library/api/SavedQueriesApi.ts @@ -0,0 +1,72 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +import { DataQuery } from '@grafana/data/src'; + +import { SavedQueryUpdateOpts } from '../components/QueryEditorDrawer'; + +import { getSavedQuerySrv } from './SavedQueriesSrv'; + +export type SavedQueryRef = { + uid?: string; +}; + +export type Variable = { + name: string; + type?: string; + current: { + value: string | number; + }; +}; + +type SavedQueryMeta = { + title: string; + description?: string; + tags?: string[]; + schemaVersion?: number; + variables: Variable[]; +}; + +type SavedQueryData = { + queries: TQuery[]; +}; + +export type SavedQuery = SavedQueryMeta & SavedQueryData & SavedQueryRef; + +export const isQueryWithMixedDatasource = (savedQuery: SavedQuery): boolean => { + if (!savedQuery?.queries?.length) { + return false; + } + + const firstDs = savedQuery.queries[0].datasource; + return savedQuery.queries.some((q) => q.datasource?.uid !== firstDs?.uid || q.datasource?.type !== firstDs?.type); +}; + +const api = createApi({ + reducerPath: 'savedQueries', + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getSavedQueryByUids: build.query({ + async queryFn(arg, queryApi, extraOptions, baseQuery) { + return { data: await getSavedQuerySrv().getSavedQueries(arg) }; + }, + }), + deleteSavedQuery: build.mutation({ + async queryFn(arg) { + await getSavedQuerySrv().deleteSavedQuery(arg); + return { + data: null, + }; + }, + }), + updateSavedQuery: build.mutation({ + async queryFn(arg) { + await getSavedQuerySrv().updateSavedQuery(arg.query, arg.opts); + return { + data: null, + }; + }, + }), + }), +}); + +export const { useUpdateSavedQueryMutation } = api; diff --git a/public/app/features/query-library/api/SavedQueriesSrv.ts b/public/app/features/query-library/api/SavedQueriesSrv.ts new file mode 100644 index 00000000000..122077626ee --- /dev/null +++ b/public/app/features/query-library/api/SavedQueriesSrv.ts @@ -0,0 +1,26 @@ +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { SavedQueryUpdateOpts } from 'app/features/query-library/components/QueryEditorDrawer'; + +import { SavedQuery, SavedQueryRef } from './SavedQueriesApi'; + +export class SavedQuerySrv { + getSavedQueries = async (refs: SavedQueryRef[]): Promise => { + if (!refs.length) { + return []; + } + const uidParams = refs.map((r) => `uid=${r.uid}`).join('&'); + return getBackendSrv().get(`/api/query-library?${uidParams}`); + }; + + deleteSavedQuery = async (ref: SavedQueryRef): Promise => { + return getBackendSrv().delete(`/api/query-library?uid=${ref.uid}`); + }; + + updateSavedQuery = async (query: SavedQuery, options: SavedQueryUpdateOpts): Promise => { + return getBackendSrv().post(`/api/query-library`, query); + }; +} + +const savedQuerySrv = new SavedQuerySrv(); + +export const getSavedQuerySrv = () => savedQuerySrv; diff --git a/public/app/features/query-library/components/CreateNewQuery.tsx b/public/app/features/query-library/components/CreateNewQuery.tsx new file mode 100644 index 00000000000..8f05e018117 --- /dev/null +++ b/public/app/features/query-library/components/CreateNewQuery.tsx @@ -0,0 +1,123 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, CodeEditor, useStyles2 } from '@grafana/ui'; + +import { SavedQuery, useUpdateSavedQueryMutation } from '../api/SavedQueriesApi'; + +import { SavedQueryUpdateOpts } from './QueryEditorDrawer'; + +type Props = { + options: SavedQueryUpdateOpts; + onDismiss: () => void; + updateComponent?: () => void; +}; + +interface QueryForm { + val: SavedQuery; +} + +const initialForm: QueryForm = { + val: { + title: 'ds-variables', + tags: [], + description: 'example description', + schemaVersion: 1, + time: { + from: 'now-6h', + to: 'now', + }, + variables: [ + { + name: 'var1', + type: 'text', + current: { + value: 'hello world', + }, + }, + ], + queries: [ + { + // @ts-ignore + channel: 'plugin/testdata/random-flakey-stream', + datasource: { + type: 'datasource', + uid: 'grafana', + }, + filter: { + fields: ['Time', 'Value'], + }, + queryType: 'measurements', + refId: 'A', + search: { + query: '', + }, + }, + { + // @ts-ignore + alias: 'my-alias', + datasource: { + type: 'testdata', + uid: 'PD8C576611E62080A', + }, + drop: 11, + hide: false, + max: 1000, + min: 10, + noise: 5, + refId: 'B', + scenarioId: 'random_walk', + startValue: 10, + }, + ], + }, +}; + +export const CreateNewQuery = ({ onDismiss, updateComponent, options }: Props) => { + const styles = useStyles2(getStyles); + + const [updateSavedQuery] = useUpdateSavedQueryMutation(); + + const [query, setQuery] = useState(initialForm); + + return ( + <> + setQuery(() => ({ val: JSON.parse(val) }))} + onSave={(val) => setQuery(() => ({ val: JSON.parse(val) }))} + readOnly={false} + /> + + + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + editor: css``, + submitButton: css` + align-self: flex-end; + margin-bottom: 25px; + margin-top: 25px; + `, + }; +}; diff --git a/public/app/features/query-library/components/DatasourceTypePicker.tsx b/public/app/features/query-library/components/DatasourceTypePicker.tsx new file mode 100644 index 00000000000..e5c34bd52f4 --- /dev/null +++ b/public/app/features/query-library/components/DatasourceTypePicker.tsx @@ -0,0 +1,113 @@ +// Libraries +import { uniqBy } from 'lodash'; +import React from 'react'; + +// Components +import { DataSourceInstanceSettings, isUnsignedPluginSignature } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getDataSourceSrv } from '@grafana/runtime/src'; +import { HorizontalGroup, PluginSignatureBadge, Select } from '@grafana/ui'; + +export type DatasourceTypePickerProps = { + onChange: (ds: string | null) => void; + current: string | null; // type + hideTextValue?: boolean; + onBlur?: () => void; + autoFocus?: boolean; + openMenuOnFocus?: boolean; + placeholder?: string; + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + /** If true,we show only DSs with logs; and if true, pluginId shouldnt be passed in */ + logs?: boolean; + width?: number; + inputId?: string; + filter?: (dataSource: DataSourceInstanceSettings) => boolean; + onClear?: () => void; +}; + +const getDataSourceTypeOptions = (props: DatasourceTypePickerProps) => { + const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } = props; + + return uniqBy( + getDataSourceSrv() + .getList({ + alerting, + tracing, + metrics, + logs, + dashboard, + mixed, + variables, + annotations, + pluginId, + filter, + type, + }) + .map((ds) => { + if (ds.type === 'datasource') { + return { + value: ds.type, + label: ds.type, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + }; + } + + return { + value: ds.type, + label: ds.type, + imgUrl: ds.meta.info.logos.small, + meta: ds.meta, + }; + }), + (opt) => opt.value + ); +}; + +export const DatasourceTypePicker = (props: DatasourceTypePickerProps) => { + const { autoFocus, onBlur, onChange, current, openMenuOnFocus, placeholder, width, inputId } = props; + const options = getDataSourceTypeOptions(props); + + return ( +
+ + {validationError && {validationError}} + + )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css` + display: flex; + align-items: center; + margin-left: ${theme.v1.spacing.xs}; + `, + nameEditIcon: css` + cursor: pointer; + color: ${theme.colors.text.secondary}; + width: 12px; + height: 12px; + `, + nameInput: css` + max-width: 300px; + margin: -8px 0; + `, + h2Style: css` + margin-bottom: 0; + `, + }; +}; diff --git a/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx b/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx new file mode 100644 index 00000000000..674376f9991 --- /dev/null +++ b/public/app/features/query-library/components/SaveQueryWorkflowModal.tsx @@ -0,0 +1,79 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Form, Modal, VerticalGroup, TextArea } from '@grafana/ui'; + +import { WorkflowID } from '../../storage/types'; +import { SavedQuery } from '../api/SavedQueriesApi'; + +interface FormDTO { + message: string; +} + +export interface SaveQueryOptions { + savedQuery: SavedQuery; + workflow: WorkflowID; + message?: string; +} + +export type SaveProps = { + onCancel: () => void; + onSuccess: () => void; + onSubmit?: (options: SaveQueryOptions) => Promise<{ success: boolean }>; + options: SaveQueryOptions; + onOptionsChange: (opts: SaveQueryOptions) => void; +}; + +export const SaveQueryWorkflowModal = ({ options, onSubmit, onCancel, onSuccess }: SaveProps) => { + const [saving, setSaving] = useState(false); + + return ( + +
{ + console.log('hello submitting!'); + if (!onSubmit) { + return; + } + setSaving(true); + options = { ...options, message: data.message }; + const result = await onSubmit(options); + if (result.success) { + onSuccess(); + } else { + setSaving(false); + } + }} + > + {({ register, errors }) => ( + +