From 8a1f43a65d131f4f172f5c722429563e722c7887 Mon Sep 17 00:00:00 2001 From: Eric Leijonmarck Date: Mon, 29 Apr 2024 12:24:14 +0100 Subject: [PATCH 01/53] User: Remove the lowercasing in the query for login conflict (#87032) * refactor: remove the lowercasing in the query for login conflict * refactor: move function into the closure gs --- pkg/services/user/userimpl/store.go | 46 +++++++++++++---------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index ea6aa5b69d355..597b649d3aa70 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -211,33 +211,29 @@ func (ss *sqlStore) GetByEmail(ctx context.Context, query *user.GetUserByEmailQu // sensitive. func (ss *sqlStore) LoginConflict(ctx context.Context, login, email string) error { err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - return ss.loginConflict(sess, login, email) - }) - return err -} + users := make([]user.User, 0) + where := "email=? OR login=?" + login = strings.ToLower(login) + email = strings.ToLower(email) -func (ss *sqlStore) loginConflict(sess *db.Session, login, email string) error { - users := make([]user.User, 0) - where := "LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)" - login = strings.ToLower(login) - email = strings.ToLower(email) - - exists, err := sess.Where(where, email, login).Get(&user.User{}) - if err != nil { - return err - } - if exists { - return user.ErrUserAlreadyExists - } - if err := sess.Where("LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)", - email, login).Find(&users); err != nil { - return err - } + exists, err := sess.Where(where, email, login).Get(&user.User{}) + if err != nil { + return err + } + if exists { + return user.ErrUserAlreadyExists + } + if err := sess.Where("LOWER(email)=LOWER(?) OR LOWER(login)=LOWER(?)", + email, login).Find(&users); err != nil { + return err + } - if len(users) > 1 { - return &user.ErrCaseInsensitiveLoginConflict{Users: users} - } - return nil + if len(users) > 1 { + return &user.ErrCaseInsensitiveLoginConflict{Users: users} + } + return nil + }) + return err } func (ss *sqlStore) Update(ctx context.Context, cmd *user.UpdateUserCommand) error { From c151a97110e9eb519bf2cf00cf18155d93a399d1 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 29 Apr 2024 13:12:36 +0100 Subject: [PATCH 02/53] Chore: Enable `no-unreduced-motion` and fix errors (#86572) * enable `no-unreduced-motion` in betterer * move all animation calls inside handleReducedMotion * fix violations + enable rule * update rule README * remove unnecessary transition from * remove handleReducedMotion utility and add handleMotion to theme * update to use new theme value * handle Dropdown and IconButton * handle AppChromeMenu and update lint message * keep rotation at a reduced speed * handle DashboardLoading --- .eslintrc | 1 + .../src/themes/createTransitions.ts | 8 ++ packages/grafana-eslint-rules/README.md | 84 ++++++++++++++++++- .../rules/no-unreduced-motion.cjs | 2 +- .../querybuilder/shared/OperationEditor.tsx | 4 +- .../AutoSaveField/EllipsisAnimated.tsx | 16 +++- .../src/components/BrowserLabel/Label.tsx | 4 +- .../src/components/Card/CardContainer.tsx | 16 ++-- .../src/components/Collapse/Collapse.tsx | 1 - .../components/ColorPicker/ColorSwatch.tsx | 8 +- .../ConfirmButton/ConfirmButton.tsx | 40 +++++---- .../CustomScrollbar/ScrollIndicators.tsx | 4 +- .../src/components/Dropdown/Dropdown.tsx | 17 ++-- .../src/components/IconButton/IconButton.tsx | 5 +- .../src/components/LoadingBar/LoadingBar.tsx | 32 +++---- .../components/PanelChrome/HoverWidget.tsx | 4 +- .../grafana-ui/src/components/Table/styles.ts | 4 +- .../ToolbarButton/ToolbarButton.tsx | 8 +- .../components/Typeahead/TypeaheadItem.tsx | 6 +- .../components/transitions/FadeTransition.tsx | 10 ++- .../transitions/SlideOutTransition.tsx | 16 +++- .../src/utils/handleReducedMotion.ts | 16 ---- packages/grafana-ui/src/utils/index.ts | 1 - packages/grafana-ui/src/utils/tooltipUtils.ts | 4 +- .../components/AppChrome/AppChromeMenu.tsx | 6 +- .../BouncingLoader/BouncingLoader.tsx | 46 +++++++--- .../app/core/components/Login/LoginLayout.tsx | 14 ++-- .../contactPoint/ContactPointSelector.tsx | 24 ++++-- .../features/canvas/elements/droneFront.tsx | 2 + .../features/canvas/elements/droneSide.tsx | 2 + .../app/features/canvas/elements/droneTop.tsx | 4 + .../canvas/elements/server/server.tsx | 4 +- .../AddLibraryPanelWidget.tsx | 8 +- .../DashboardLoading/DashboardLoading.tsx | 4 +- public/app/features/explore/ExploreDrawer.tsx | 4 +- .../features/explore/Logs/LogsTableWrap.tsx | 4 +- .../VizTypePicker/PanelTypeCard.tsx | 8 +- .../admin/components/PluginListItem.tsx | 8 +- .../timeseries/plugins/ExemplarMarker.tsx | 4 +- 39 files changed, 321 insertions(+), 132 deletions(-) delete mode 100644 packages/grafana-ui/src/utils/handleReducedMotion.ts diff --git a/.eslintrc b/.eslintrc index c61459edfad28..71d0b6fbcacab 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,6 +8,7 @@ }, "rules": { "@grafana/no-border-radius-literal": "error", + "@grafana/no-unreduced-motion": "error", "react/prop-types": "off", // need to ignore emotion's `css` prop, see https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md#rule-options "react/no-unknown-property": ["error", { "ignore": ["css"] }], diff --git a/packages/grafana-data/src/themes/createTransitions.ts b/packages/grafana-data/src/themes/createTransitions.ts index d15b821aba5be..723978b1741e9 100644 --- a/packages/grafana-data/src/themes/createTransitions.ts +++ b/packages/grafana-data/src/themes/createTransitions.ts @@ -53,6 +53,12 @@ export function create(props: string | string[] = ['all'], options: CreateTransi .join(','); } +type ReducedMotionProps = 'no-preference' | 'reduce'; + +export function handleMotion(...props: ReducedMotionProps[]) { + return props.map((prop) => `@media (prefers-reduced-motion: ${prop})`).join(','); +} + export function getAutoHeightDuration(height: number) { if (!height) { return 0; @@ -74,6 +80,7 @@ export interface ThemeTransitions { duration: typeof duration; easing: typeof easing; getAutoHeightDuration: typeof getAutoHeightDuration; + handleMotion: typeof handleMotion; } /** @internal */ @@ -83,5 +90,6 @@ export function createTransitions(): ThemeTransitions { duration, easing, getAutoHeightDuration, + handleMotion, }; } diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index 187e1d2deb9a2..1fb043a4ec027 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -24,7 +24,89 @@ Avoid direct use of `animation*` or `transition*` properties. To account for users with motion sensitivities, these should always be wrapped in a [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query. -`@grafana/ui` exposes a `handledReducedMotion` utility function that can be used to handle this. +There is a `handleMotion` utility function exposed on the theme that can help with this. + +#### Examples + +```tsx +// Bad ❌ +const getStyles = (theme: GrafanaTheme2) => ({ + loading: css({ + animationName: rotate, + animationDuration: '2s', + animationIterationCount: 'infinite', + }), +}); + +// Good ✅ +const getStyles = (theme: GrafanaTheme2) => ({ + loading: css({ + [theme.transitions.handleMotion('no-preference')]: { + animationName: rotate, + animationDuration: '2s', + animationIterationCount: 'infinite', + }, + [theme.transitions.handleMotion('reduce')]: { + animationName: pulse, + animationDuration: '2s', + animationIterationCount: 'infinite', + }, + }), +}); + +// Good ✅ +const getStyles = (theme: GrafanaTheme2) => ({ + loading: css({ + '@media (prefers-reduced-motion: no-preference)': { + animationName: rotate, + animationDuration: '2s', + animationIterationCount: 'infinite', + }, + '@media (prefers-reduced-motion: reduce)': { + animationName: pulse, + animationDuration: '2s', + animationIterationCount: 'infinite', + }, + }), +}); +``` + +Note we've switched the potentially sensitive rotating animation to a less intense pulse animation when `prefers-reduced-motion` is set. + +Animations that involve only non-moving properties, like opacity, color, and blurs, are unlikely to be problematic. In those cases, you still need to wrap the animation in a `prefers-reduced-motion` media query, but you can use the same animation for both cases: + +```tsx +// Bad ❌ +const getStyles = (theme: GrafanaTheme2) => ({ + card: css({ + transition: theme.transitions.create(['background-color'], { + duration: theme.transitions.duration.short, + }), + }), +}); + +// Good ✅ +const getStyles = (theme: GrafanaTheme2) => ({ + card: css({ + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color'], { + duration: theme.transitions.duration.short, + }), + }, + }), +}); + +// Good ✅ +const getStyles = (theme: GrafanaTheme2) => ({ + card: css({ + '@media (prefers-reduced-motion: no-preference), @media (prefers-reduced-motion: reduce)': { + transition: theme.transitions.create(['background-color'], { + duration: theme.transitions.duration.short, + }), + }, + }), +}); +``` ### `theme-token-usage` diff --git a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs index a8f673a4be2e2..a1b279fb861dd 100644 --- a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs +++ b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs @@ -55,7 +55,7 @@ const rule = createRule({ description: 'Check if animation or transition properties are used directly.', }, messages: { - noUnreducedMotion: 'Avoid direct use of `animation*` or `transition*` properties. Use the `handleReducedMotion` utility function or wrap in a `prefers-reduced-motion` media query.', + noUnreducedMotion: 'Avoid direct use of `animation*` or `transition*` properties. Use the `handleMotion` utility function from theme.transitions or wrap in a `prefers-reduced-motion` media query.', }, schema: [], }, diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx index 405b40b7cd2df..a2d23aacdc9d8 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx @@ -238,7 +238,9 @@ const getStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.default, marginBottom: theme.spacing(1), position: 'relative', - transition: 'all 0.5s ease-in 0s', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'all 0.5s ease-in 0s', + }, height: '100%', }), cardError: css({ diff --git a/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx b/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx index 1a91db3120dbf..bac0fe4445d0e 100644 --- a/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx +++ b/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx @@ -1,6 +1,8 @@ import { css, keyframes } from '@emotion/css'; import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; + import { useStyles2 } from '../../themes'; export const EllipsisAnimated = React.memo(() => { @@ -16,19 +18,25 @@ export const EllipsisAnimated = React.memo(() => { EllipsisAnimated.displayName = 'EllipsisAnimated'; -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { ellipsis: css({ display: 'inline', }), firstDot: css({ - animation: `${firstDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${firstDot} 2s linear infinite`, + }, }), secondDot: css({ - animation: `${secondDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${secondDot} 2s linear infinite`, + }, }), thirdDot: css({ - animation: `${thirdDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${thirdDot} 2s linear infinite`, + }, }), }; }; diff --git a/packages/grafana-ui/src/components/BrowserLabel/Label.tsx b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx index 1b83a57bc2687..69d09b4aafdc3 100644 --- a/packages/grafana-ui/src/components/BrowserLabel/Label.tsx +++ b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx @@ -120,7 +120,9 @@ const getLabelStyles = (theme: GrafanaTheme2) => ({ fontWeight: theme.typography.fontWeightMedium, backgroundColor: theme.colors.primary.shade, color: theme.colors.text.primary, - animation: 'pulse 3s ease-out 0s infinite normal forwards', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: 'pulse 3s ease-out 0s infinite normal forwards', + }, '@keyframes pulse': { '0%': { color: theme.colors.text.primary, diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index 8879a87ed4f55..15ba01c96515d 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -94,9 +94,11 @@ export const getCardContainerStyles = ( borderRadius: theme.shape.radius.default, marginBottom: '8px', pointerEvents: disabled ? 'none' : 'auto', - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, ...(!disableHover && { '&:hover': { @@ -123,9 +125,11 @@ export const getCardContainerStyles = ( position: 'relative', pointerEvents: disabled ? 'none' : 'auto', marginBottom: theme.spacing(1), - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, ...(!disableHover && { '&:hover': { diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index a1526af615411..d934ee52285b9 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -71,7 +71,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ label: 'collapse__header', padding: theme.spacing(1, 2, 1, 2), display: 'flex', - transition: 'all 0.1s linear', }), headerCollapsed: css({ label: 'collapse__header--collapsed', diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx index f3827df404e71..97cdabdc1c1f9 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx @@ -80,9 +80,11 @@ const getStyles = ( boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}` : 'none', - transition: theme.transitions.create(['transform'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { transform: 'scale(1.1)', }, diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index 850da68b49e4b..d8c539fc43c6b 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -133,18 +133,22 @@ const getStyles = (theme: GrafanaTheme2) => { }), mainButton: css({ opacity: 1, - transition: theme.transitions.create(['opacity'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeOut, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), + }, zIndex: 2, }), mainButtonHide: css({ opacity: 0, - transition: theme.transitions.create(['opacity', 'visibility'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeIn, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), + }, visibility: 'hidden', zIndex: 0, }), @@ -164,19 +168,23 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', opacity: 1, transform: 'translateX(0)', - transition: theme.transitions.create(['opacity', 'transform'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeOut, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'transform'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), + }, zIndex: 1, }), confirmButtonHide: css({ opacity: 0, transform: 'translateX(100%)', - transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeIn, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), + }, visibility: 'hidden', }), }; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx index b22711457ffca..ef80d3df6a1f5 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx @@ -65,7 +65,9 @@ const getStyles = (theme: GrafanaTheme2) => { pointerEvents: 'none', position: 'absolute', right: 0, - transition: theme.transitions.create('opacity'), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create('opacity'), + }, zIndex: 1, }), scrollTopIndicator: css({ diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index f85465cba7f85..94cfac03fb95f 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -13,7 +13,10 @@ import { import React, { useEffect, useRef, useState } from 'react'; import { CSSTransition } from 'react-transition-group'; -import { ReactUtils, handleReducedMotion } from '../../utils'; +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { ReactUtils } from '../../utils'; import { getPlacement } from '../../utils/tooltipUtils'; import { Portal } from '../Portal/Portal'; import { TooltipPlacement } from '../Tooltip/types'; @@ -63,7 +66,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); const animationDuration = 150; - const animationStyles = getStyles(animationDuration); + const animationStyles = useStyles2(getStyles, animationDuration); const onOverlayClicked = () => { setShow(false); @@ -109,22 +112,22 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi Dropdown.displayName = 'Dropdown'; -const getStyles = (duration: number) => { +const getStyles = (theme: GrafanaTheme2, duration: number) => { return { appear: css({ opacity: '0', position: 'relative', transformOrigin: 'top', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { transform: 'scaleY(0.5)', - }), + }, }), appearActive: css({ opacity: '1', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { transform: 'scaleY(1)', transition: `transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1)`, - }), + }, }), }; }; diff --git a/packages/grafana-ui/src/components/IconButton/IconButton.tsx b/packages/grafana-ui/src/components/IconButton/IconButton.tsx index fcfbb8f3d2df0..b0cbcba5c4ac7 100644 --- a/packages/grafana-ui/src/components/IconButton/IconButton.tsx +++ b/packages/grafana-ui/src/components/IconButton/IconButton.tsx @@ -7,7 +7,6 @@ import { useStyles2 } from '../../themes'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; import { ComponentSize } from '../../types'; import { IconName, IconSize, IconType } from '../../types/icon'; -import { handleReducedMotion } from '../../utils/handleReducedMotion'; import { Icon } from '../Icon/Icon'; import { getSvgSize } from '../Icon/utils'; import { TooltipPlacement, PopoverContent, Tooltip } from '../Tooltip'; @@ -144,11 +143,11 @@ const getStyles = (theme: GrafanaTheme2, size: IconSize, variant: IconButtonVari height: `${hoverSize}px`, borderRadius: theme.shape.radius.default, content: '""', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference', 'reduce')]: { transitionDuration: '0.2s', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionProperty: 'opacity', - }), + }, }, '&:focus, &:focus-visible': getFocusStyles(theme), diff --git a/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx b/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx index 07186be5e8c1e..29091d7bb3660 100644 --- a/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx +++ b/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx @@ -4,7 +4,6 @@ import React, { CSSProperties } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { handleReducedMotion } from '../../utils/handleReducedMotion'; export interface LoadingBarProps { width: number; @@ -33,7 +32,7 @@ export function LoadingBar({ width, delay = DEFAULT_ANIMATION_DELAY, ariaLabel = ); } -const getStyles = (_theme: GrafanaTheme2, delay: number, duration: number) => { +const getStyles = (theme: GrafanaTheme2, delay: number, duration: number) => { const animation = keyframes({ '0%': { transform: 'translateX(-100%)', @@ -50,20 +49,23 @@ const getStyles = (_theme: GrafanaTheme2, delay: number, duration: number) => { height: 1, background: 'linear-gradient(90deg, rgba(110, 159, 255, 0) 0%, #6E9FFF 80.75%, rgba(110, 159, 255, 0) 100%)', transform: 'translateX(-100%)', - animationName: animation, - // an initial delay to prevent the loader from showing if the response is faster than the delay - animationDelay: `${delay}ms`, - animationTimingFunction: 'linear', - animationIterationCount: 'infinite', willChange: 'transform', - ...handleReducedMotion( - { - animationDuration: `${duration}ms`, - }, - { - animationDuration: `${4 * duration}ms`, - } - ), + [theme.transitions.handleMotion('no-preference')]: { + animationName: animation, + // an initial delay to prevent the loader from showing if the response is faster than the delay + animationDelay: `${delay}ms`, + animationTimingFunction: 'linear', + animationIterationCount: 'infinite', + animationDuration: `${duration}ms`, + }, + [theme.transitions.handleMotion('reduce')]: { + animationName: animation, + // an initial delay to prevent the loader from showing if the response is faster than the delay + animationDelay: `${delay}ms`, + animationTimingFunction: 'linear', + animationIterationCount: 'infinite', + animationDuration: `${4 * duration}ms`, + }, }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx index 2bfe7ef5e20ac..b0fda6d10130d 100644 --- a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx @@ -66,7 +66,9 @@ function getStyles(theme: GrafanaTheme2) { return { container: css({ label: 'hover-container-widget', - transition: `all .1s linear`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `all .1s linear`, + }, display: 'flex', position: 'absolute', zIndex: 1, diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 9ef525ac005dc..a7647c978aa1d 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -263,7 +263,9 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell display: 'inline-block', background: resizerColor, opacity: 0, - transition: 'opacity 0.2s ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.2s ease-in-out', + }, width: '8px', height: '100%', position: 'absolute', diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index 9bebc40b83c5b..767ecd7d81bac 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -152,9 +152,11 @@ const getStyles = (theme: GrafanaTheme2) => { fontWeight: theme.typography.fontWeightMedium, border: `1px solid ${theme.colors.secondary.border}`, whiteSpace: 'nowrap', - transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, '&:focus, &:focus-visible': { ...getFocusStyles(theme), diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx index b230f7f6cd792..de90392647b68 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx @@ -36,8 +36,10 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'block', whiteSpace: 'nowrap', cursor: 'pointer', - transition: - 'color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1)', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: + 'color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1)', + }, }), typeaheadItemSelected: css({ diff --git a/packages/grafana-ui/src/components/transitions/FadeTransition.tsx b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx index c1d9d6be2232b..501a0dfe40ec0 100644 --- a/packages/grafana-ui/src/components/transitions/FadeTransition.tsx +++ b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx @@ -23,7 +23,7 @@ export function FadeTransition(props: Props) { ); } -const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ +const getStyles = (theme: GrafanaTheme2, duration: number) => ({ enter: css({ label: 'enter', opacity: 0, @@ -31,7 +31,9 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ enterActive: css({ label: 'enterActive', opacity: 1, - transition: `opacity ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), exit: css({ label: 'exit', @@ -40,6 +42,8 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ exitActive: css({ label: 'exitActive', opacity: 0, - transition: `opacity ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), }); diff --git a/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx b/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx index 5981f28d49e0a..a12a4ba17003b 100644 --- a/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx +++ b/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx @@ -26,7 +26,7 @@ export function SlideOutTransition(props: Props) { ); } -const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' | 'height', size: number) => ({ +const getStyles = (theme: GrafanaTheme2, duration: number, measurement: 'width' | 'height', size: number) => ({ enter: css({ label: 'enter', [`${measurement}`]: 0, @@ -36,7 +36,12 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' label: 'enterActive', [`${measurement}`]: `${size}px`, opacity: 1, - transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference')]: { + transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, + }, + [theme.transitions.handleMotion('reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), exit: css({ label: 'exit', @@ -47,6 +52,11 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' label: 'exitActive', opacity: 0, [`${measurement}`]: 0, - transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference')]: { + transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, + }, + [theme.transitions.handleMotion('reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), }); diff --git a/packages/grafana-ui/src/utils/handleReducedMotion.ts b/packages/grafana-ui/src/utils/handleReducedMotion.ts deleted file mode 100644 index 1ca85249dbe4a..0000000000000 --- a/packages/grafana-ui/src/utils/handleReducedMotion.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CSSInterpolation } from '@emotion/css'; - -/** - * @param styles - Styles to apply when no `prefers-reduced-motion` preference is set. - * @param reducedMotionStyles - Styles to apply when `prefers-reduced-motion` is enabled. - * Applies one of `styles` or `reducedMotionStyles` depending on a users `prefers-reduced-motion` setting. Omitting `reducedMotionStyles` entirely will result in no styles being applied when `prefers-reduced-motion` is enabled. In most cases this is a reasonable default. - */ -export const handleReducedMotion = (styles: CSSInterpolation, reducedMotionStyles?: CSSInterpolation) => { - const result: Record = { - '@media (prefers-reduced-motion: no-preference)': styles, - }; - if (reducedMotionStyles) { - result['@media (prefers-reduced-motion: reduce)'] = reducedMotionStyles; - } - return result; -}; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 55a1694e8b7be..b4ec365d71707 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -19,6 +19,5 @@ export { createLogger } from './logger'; export { attachDebugger } from './debug'; export * from './nodeGraph'; export { fuzzyMatch } from './fuzzy'; -export { handleReducedMotion } from './handleReducedMotion'; export { ReactUtils }; diff --git a/packages/grafana-ui/src/utils/tooltipUtils.ts b/packages/grafana-ui/src/utils/tooltipUtils.ts index b666ca85b294f..821b9de5f7f8f 100644 --- a/packages/grafana-ui/src/utils/tooltipUtils.ts +++ b/packages/grafana-ui/src/utils/tooltipUtils.ts @@ -37,7 +37,9 @@ export function buildTooltipTheme( color: tooltipText, fontSize: theme.typography.bodySmall.fontSize, padding: theme.spacing(tooltipPadding.topBottom, tooltipPadding.rightLeft), - transition: 'opacity 0.3s', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.3s', + }, zIndex: theme.zIndex.tooltip, maxWidth: '400px', overflowWrap: 'break-word', diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index 88b8db031ce1b..ee58fb39bc640 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -6,7 +6,7 @@ import React, { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; import { GrafanaTheme2 } from '@grafana/data'; -import { handleReducedMotion, useStyles2, useTheme2 } from '@grafana/ui'; +import { useStyles2, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { KioskMode } from 'app/types'; @@ -125,10 +125,10 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { const commonTransition = { - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { transitionDuration: `${animationDuration}ms`, transitionTimingFunction: theme.transitions.easing.easeInOut, - }), + }, [theme.breakpoints.down('md')]: { overflow: 'hidden', }, diff --git a/public/app/core/components/BouncingLoader/BouncingLoader.tsx b/public/app/core/components/BouncingLoader/BouncingLoader.tsx index 21e7601053220..e3273663af455 100644 --- a/public/app/core/components/BouncingLoader/BouncingLoader.tsx +++ b/public/app/core/components/BouncingLoader/BouncingLoader.tsx @@ -33,6 +33,18 @@ const fadeIn = keyframes({ }, }); +const pulse = keyframes({ + '0%': { + opacity: 0, + }, + '50%': { + opacity: 1, + }, + '100%': { + opacity: 0, + }, +}); + const bounce = keyframes({ 'from, to': { transform: 'translateY(0px)', @@ -70,25 +82,37 @@ const squash = keyframes({ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ opacity: 0, - animationName: fadeIn, - animationIterationCount: 1, - animationDuration: '0.9s', - animationDelay: '0.5s', - animationFillMode: 'forwards', + [theme.transitions.handleMotion('no-preference')]: { + animationName: fadeIn, + animationIterationCount: 1, + animationDuration: '0.9s', + animationDelay: '0.5s', + animationFillMode: 'forwards', + }, + [theme.transitions.handleMotion('reduce')]: { + animationName: pulse, + animationIterationCount: 'infinite', + animationDuration: '4s', + animationDelay: '0.5s', + }, }), bounce: css({ textAlign: 'center', - animationName: bounce, - animationDuration: '0.9s', - animationIterationCount: 'infinite', + [theme.transitions.handleMotion('no-preference')]: { + animationName: bounce, + animationDuration: '0.9s', + animationIterationCount: 'infinite', + }, }), logo: css({ display: 'inline-block', - animationName: squash, - animationDuration: '0.9s', - animationIterationCount: 'infinite', + [theme.transitions.handleMotion('no-preference')]: { + animationName: squash, + animationDuration: '0.9s', + animationIterationCount: 'infinite', + }, width: '60px', height: '60px', }), diff --git a/public/app/core/components/Login/LoginLayout.tsx b/public/app/core/components/Login/LoginLayout.tsx index cb391662213bc..c7ef7649be561 100644 --- a/public/app/core/components/Login/LoginLayout.tsx +++ b/public/app/core/components/Login/LoginLayout.tsx @@ -2,7 +2,7 @@ import { cx, css, keyframes } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { handleReducedMotion, useStyles2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { Branding } from '../Branding/Branding'; import { BrandingSettings } from '../Branding/types'; @@ -148,7 +148,9 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.default, padding: theme.spacing(2, 0), opacity: 0, - transition: 'opacity 0.5s ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.5s ease-in-out', + }, [theme.breakpoints.up('sm')]: { minHeight: theme.spacing(40), @@ -171,12 +173,14 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { maxWidth: 415, width: '100%', transform: 'translate(0px, 0px)', - transition: '0.25s ease', + [theme.transitions.handleMotion('no-preference')]: { + transition: '0.25s ease', + }, }), enterAnimation: css({ - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { animation: `${flyInAnimation} ease-out 0.2s`, - }), + }, }), }; }; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index 7914bd495b036..8b5abe2c7c47e 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -1,4 +1,4 @@ -import { css, cx } from '@emotion/css'; +import { css, cx, keyframes } from '@emotion/css'; import React, { useCallback, useEffect, useState } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; @@ -149,6 +149,15 @@ function LinkToContactPoints() { ); } +const rotation = keyframes({ + from: { + transform: 'rotate(720deg)', + }, + to: { + transform: 'rotate(0deg)', + }, +}); + const getStyles = (theme: GrafanaTheme2) => ({ contactPointsSelector: css({ display: 'flex', @@ -172,14 +181,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), loading: css({ pointerEvents: 'none', - animation: 'rotation 2s infinite linear', - '@keyframes rotation': { - from: { - transform: 'rotate(720deg)', - }, - to: { - transform: 'rotate(0deg)', - }, + [theme.transitions.handleMotion('no-preference')]: { + animation: `${rotation} 2s infinite linear`, + }, + [theme.transitions.handleMotion('reduce')]: { + animation: `${rotation} 6s infinite linear`, }, }), warn: css({ diff --git a/public/app/features/canvas/elements/droneFront.tsx b/public/app/features/canvas/elements/droneFront.tsx index 29921fced33d7..031df8af28b72 100644 --- a/public/app/features/canvas/elements/droneFront.tsx +++ b/public/app/features/canvas/elements/droneFront.tsx @@ -122,6 +122,8 @@ export const droneFrontItem: CanvasElementItem = { const getStyles = (theme: GrafanaTheme2) => ({ droneFront: css({ + // TODO: figure out what styles to apply when prefers-reduced-motion is set + // eslint-disable-next-line @grafana/no-unreduced-motion transition: 'transform 0.4s', }), }); diff --git a/public/app/features/canvas/elements/droneSide.tsx b/public/app/features/canvas/elements/droneSide.tsx index aa9ad4b95614d..a82b11d3ff15a 100644 --- a/public/app/features/canvas/elements/droneSide.tsx +++ b/public/app/features/canvas/elements/droneSide.tsx @@ -121,6 +121,8 @@ export const droneSideItem: CanvasElementItem = { const getStyles = (theme: GrafanaTheme2) => ({ droneSide: css({ + // TODO: figure out what styles to apply when prefers-reduced-motion is set + // eslint-disable-next-line @grafana/no-unreduced-motion transition: 'transform 0.4s', }), }); diff --git a/public/app/features/canvas/elements/droneTop.tsx b/public/app/features/canvas/elements/droneTop.tsx index f4c2bff38f7c9..31584b0058351 100644 --- a/public/app/features/canvas/elements/droneTop.tsx +++ b/public/app/features/canvas/elements/droneTop.tsx @@ -180,9 +180,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ }, }), propellerCW: css({ + // TODO: figure out what styles to apply when prefers-reduced-motion is set + // eslint-disable-next-line @grafana/no-unreduced-motion animationDirection: 'normal', }), propellerCCW: css({ + // TODO: figure out what styles to apply when prefers-reduced-motion is set + // eslint-disable-next-line @grafana/no-unreduced-motion animationDirection: 'reverse', }), }); diff --git a/public/app/features/canvas/elements/server/server.tsx b/public/app/features/canvas/elements/server/server.tsx index 633959b929cce..cdb69ea7fbdd4 100644 --- a/public/app/features/canvas/elements/server/server.tsx +++ b/public/app/features/canvas/elements/server/server.tsx @@ -173,7 +173,9 @@ export const getServerStyles = (data: ServerData | undefined) => (theme: Grafana fill: data?.statusColor ?? 'transparent', }), circle: css({ - animation: `blink ${data?.blinkRate ? 1 / data.blinkRate : 0}s infinite step-end`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `blink ${data?.blinkRate ? 1 / data.blinkRate : 0}s infinite step-end`, + }, fill: data?.bulbColor, stroke: 'none', }), diff --git a/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx b/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx index cda9bf63d9e05..345ab73e3523c 100644 --- a/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx @@ -90,7 +90,9 @@ const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.fontSize, fontWeight: theme.typography.fontWeightMedium, paddingLeft: `${theme.spacing(1)}`, - transition: 'background-color 0.1s ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'background-color 0.1s ease-in-out', + }, cursor: 'move', '&:hover': { @@ -102,7 +104,9 @@ const getStyles = (theme: GrafanaTheme2) => { outline: '2px dotted transparent', outlineOffset: '2px', boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4', - animation: `${pulsate} 2s ease infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${pulsate} 2s ease infinite`, + }, }), }; }; diff --git a/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx index a93406e4a3985..8bd51c865b827 100644 --- a/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx +++ b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx @@ -50,7 +50,9 @@ export const getStyles = (theme: GrafanaTheme2) => { opacity: '0%', alignItems: 'center', justifyContent: 'center', - animation: `${invisibleToVisible} 0s step-end ${slowStartThreshold} 1 normal forwards`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${invisibleToVisible} 0s step-end ${slowStartThreshold} 1 normal forwards`, + }, }), dashboardLoadingText: css({ fontSize: theme.typography.h4.fontSize, diff --git a/public/app/features/explore/ExploreDrawer.tsx b/public/app/features/explore/ExploreDrawer.tsx index af4c41922e468..2b5c82f0b9b0c 100644 --- a/public/app/features/explore/ExploreDrawer.tsx +++ b/public/app/features/explore/ExploreDrawer.tsx @@ -68,6 +68,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), drawerActive: css({ opacity: 1, - animation: `0.5s ease-out ${drawerSlide(theme)}`, + [theme.transitions.handleMotion('no-preference')]: { + animation: `0.5s ease-out ${drawerSlide(theme)}`, + }, }), }); diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 9580773368a81..d80f620475ad9 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -567,7 +567,9 @@ function getStyles(theme: GrafanaTheme2, height: number, width: number) { }), rzHandle: css({ background: theme.colors.secondary.main, - transition: '0.3s background ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: '0.3s background ease-in-out', + }, position: 'relative', height: '50% !important', width: `${theme.spacing(1)} !important`, diff --git a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx index ba6bad69f3511..8b651759f6ea8 100644 --- a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx @@ -138,9 +138,11 @@ const getStyles = (theme: GrafanaTheme2) => { padding: theme.spacing(1), width: '100%', overflow: 'hidden', - transition: theme.transitions.create(['background'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), diff --git a/public/app/features/plugins/admin/components/PluginListItem.tsx b/public/app/features/plugins/admin/components/PluginListItem.tsx index a512b23be16b9..584656809c0c9 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.tsx @@ -100,9 +100,11 @@ export const getStyles = (theme: GrafanaTheme2) => { background: theme.colors.background.secondary, borderRadius: theme.shape.radius.default, padding: theme.spacing(3), - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx index 54c42a22830ce..04c1c95b57421 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx @@ -307,7 +307,9 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => { marble: css({ display: 'block', opacity: 0.5, - transition: 'transform 0.15s ease-out', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'transform 0.15s ease-out', + }, }), activeMarble: css({ transform: 'scale(1.3)', From f1e8d2645f69dd9b9f1d8da16cd43ea8bfce9098 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Mon, 29 Apr 2024 14:14:06 +0200 Subject: [PATCH 03/53] Loki: update HorizontalGroup deprecation (#86995) * Loki: update HorizontalGroup deprecation * Update betterer --- .betterer.results | 10 ++++------ .../datasource/loki/components/LokiLabelBrowser.tsx | 6 +++--- .../querybuilder/components/LokiQueryCodeEditor.tsx | 6 +++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.betterer.results b/.betterer.results index 33f5b3eb6127e..c38c7f43ca436 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5262,7 +5262,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "10"] ], "public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], + [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "Styles should be written using objects.", "3"], @@ -5275,8 +5275,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "10"], [0, 0, 0, "Styles should be written using objects.", "11"], [0, 0, 0, "Styles should be written using objects.", "12"], - [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"] + [0, 0, 0, "Styles should be written using objects.", "13"] ], "public/app/plugins/datasource/loki/components/monaco-query-field/MonacoQueryField.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], @@ -5313,10 +5312,9 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], + [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"] + [0, 0, 0, "Styles should be written using objects.", "2"] ], "public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx index bbe205f93fa1c..cbc8930bfa57d 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx @@ -8,13 +8,13 @@ import { reportInteraction } from '@grafana/runtime'; import { Button, HighlightPart, - HorizontalGroup, Input, Label, LoadingPlaceholder, withTheme2, BrowserLabel as LokiLabel, fuzzyMatch, + Stack, } from '@grafana/ui'; import LokiLanguageProvider from '../LanguageProvider'; @@ -533,7 +533,7 @@ export class UnthemedLokiLabelBrowser extends React.Component {error || status} - + @@ -556,7 +556,7 @@ export class UnthemedLokiLabelBrowser extends React.Component Clear - + ); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx index 5db5313f98514..2d45f8c96ebab 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryCodeEditor.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { useStyles2, HorizontalGroup, IconButton, Tooltip, Icon } from '@grafana/ui'; +import { useStyles2, IconButton, Tooltip, Icon, Stack } from '@grafana/ui'; import { testIds } from '../../components/LokiQueryEditor'; import { LokiQueryField } from '../../components/LokiQueryField'; @@ -49,7 +49,7 @@ export function LokiQueryCodeEditor({ {lokiFormatQuery && (
- + - +
)} From 5830d6761d745740db5c81c9c2be58bcb7b94601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Mon, 29 Apr 2024 14:33:00 +0200 Subject: [PATCH 04/53] ifrost/fix-link (#86965) * Fix link label * Switch to lowercase --- public/app/features/explore/ShortLinkButtonMenu.tsx | 4 ++-- public/locales/en-US/grafana.json | 4 ++-- public/locales/pseudo-LOCALE/grafana.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/app/features/explore/ShortLinkButtonMenu.tsx b/public/app/features/explore/ShortLinkButtonMenu.tsx index 5c7bebcc00f9a..36451f39ead53 100644 --- a/public/app/features/explore/ShortLinkButtonMenu.tsx +++ b/public/app/features/explore/ShortLinkButtonMenu.tsx @@ -83,7 +83,7 @@ export function ShortLinkButtonMenu() { { key: 'copy-short-link-abs-time', icon: 'clock-nine', - label: t('explore.toolbar.copy-shortened-link-abs-time', 'Copy Absolute Shortened URL'), + label: t('explore.toolbar.copy-shortened-link-abs-time', 'Copy absolute shortened URL'), shorten: true, getUrl: () => { return constructAbsoluteUrl(panes); @@ -93,7 +93,7 @@ export function ShortLinkButtonMenu() { { key: 'copy-link-abs-time', icon: 'clock-nine', - label: t('explore.toolbar.copy-link-abs-time', 'Copy Absolute Shortened URL'), + label: t('explore.toolbar.copy-link-abs-time', 'Copy absolute URL'), shorten: false, getUrl: () => { return constructAbsoluteUrl(panes); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b14cc1fe96e47..812dcbcf66acd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -584,11 +584,11 @@ "toolbar": { "aria-label": "Explore toolbar", "copy-link": "Copy URL", - "copy-link-abs-time": "Copy Absolute Shortened URL", + "copy-link-abs-time": "Copy absolute URL", "copy-links-absolute-category": "Time-sync URL links (share with time range intact)", "copy-links-normal-category": "Normal URL links", "copy-shortened-link": "Copy shortened URL", - "copy-shortened-link-abs-time": "Copy Absolute Shortened URL", + "copy-shortened-link-abs-time": "Copy absolute shortened URL", "copy-shortened-link-menu": "Open copy link options", "refresh-picker-cancel": "Cancel", "refresh-picker-run": "Run query", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 65abad5652fa7..6da92d43459b3 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -584,11 +584,11 @@ "toolbar": { "aria-label": "Ēχpľőřę ŧőőľþäř", "copy-link": "Cőpy ŮŖĿ", - "copy-link-abs-time": "Cőpy Åþşőľūŧę Ŝĥőřŧęʼnęđ ŮŖĿ", + "copy-link-abs-time": "Cőpy äþşőľūŧę ŮŖĿ", "copy-links-absolute-category": "Ŧįmę-şyʼnč ŮŖĿ ľįʼnĸş (şĥäřę ŵįŧĥ ŧįmę řäʼnģę įʼnŧäčŧ)", "copy-links-normal-category": "Ńőřmäľ ŮŖĿ ľįʼnĸş", "copy-shortened-link": "Cőpy şĥőřŧęʼnęđ ŮŖĿ", - "copy-shortened-link-abs-time": "Cőpy Åþşőľūŧę Ŝĥőřŧęʼnęđ ŮŖĿ", + "copy-shortened-link-abs-time": "Cőpy äþşőľūŧę şĥőřŧęʼnęđ ŮŖĿ", "copy-shortened-link-menu": "Øpęʼn čőpy ľįʼnĸ őpŧįőʼnş", "refresh-picker-cancel": "Cäʼnčęľ", "refresh-picker-run": "Ŗūʼn qūęřy", From b52e349639f562c206cf61f101ac9d85477ec574 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:50:03 +0200 Subject: [PATCH 05/53] Explore and Correlations: Replace deprecated layout components (#86967) Replace deprecated layout components --- .betterer.results | 9 --------- .../correlations/Forms/CorrelationFormNavigation.tsx | 6 +++--- public/app/features/explore/CorrelationEditorModeBar.tsx | 6 +++--- .../explore/extensions/ConfirmNavigationModal.tsx | 6 +++--- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/.betterer.results b/.betterer.results index c38c7f43ca436..7badfe9042920 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2379,9 +2379,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], - "public/app/features/correlations/Forms/CorrelationFormNavigation.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/correlations/components/Wizard/index.ts:5381": [ [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], [0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"] @@ -3011,9 +3008,6 @@ exports[`better eslint`] = { "public/app/features/explore/ContentOutline/ContentOutline.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/explore/CorrelationEditorModeBar.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/explore/Logs/LiveLogs.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -3445,9 +3439,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/explore/extensions/ConfirmNavigationModal.tsx:5381": [ - [0, 0, 0, "\'VerticalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] - ], "public/app/features/explore/hooks/useStateSync/index.ts:5381": [ [0, 0, 0, "Do not re-export imported variable (\`./external.utils\`)", "0"] ], diff --git a/public/app/features/correlations/Forms/CorrelationFormNavigation.tsx b/public/app/features/correlations/Forms/CorrelationFormNavigation.tsx index 831e3a61260cd..95d6d56fc24b4 100644 --- a/public/app/features/correlations/Forms/CorrelationFormNavigation.tsx +++ b/public/app/features/correlations/Forms/CorrelationFormNavigation.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Button, HorizontalGroup } from '@grafana/ui'; +import { Button, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { useWizardContext } from '../components/Wizard/wizardContext'; @@ -26,7 +26,7 @@ export const CorrelationFormNavigation = () => { ); return ( - + {currentPage > 0 ? ( - + ); diff --git a/public/app/features/explore/extensions/ConfirmNavigationModal.tsx b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx index b9481582e809b..e9c41612adf29 100644 --- a/public/app/features/explore/extensions/ConfirmNavigationModal.tsx +++ b/public/app/features/explore/extensions/ConfirmNavigationModal.tsx @@ -2,7 +2,7 @@ import React, { ReactElement } from 'react'; import { locationUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { Button, Modal, VerticalGroup } from '@grafana/ui'; +import { Button, Modal, Stack } from '@grafana/ui'; type Props = { onDismiss: () => void; @@ -20,9 +20,9 @@ export function ConfirmNavigationModal(props: Props): ReactElement { return ( - +

Do you want to proceed in the current tab or open a new tab?

-
+ )} - {!loading && } + {!isLoading && } Cancel diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 3d8c21c4b8313..5ebd45fc74847 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -1,14 +1,17 @@ import { css } from '@emotion/css'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { dateMath, GrafanaTheme2 } from '@grafana/data'; -import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack } from '@grafana/ui'; +import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack, Alert } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi'; +import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi'; +import { featureDiscoveryApi } from 'app/features/alerting/unified/api/featureDiscoveryApi'; +import { SILENCES_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants'; +import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource'; import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types'; -import { useDispatch } from 'app/types'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; -import { expireSilenceAction } from '../../state/actions'; import { parseMatchers } from '../../utils/alertmanager'; import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc'; import { Authorize } from '../Authorize'; @@ -29,12 +32,30 @@ export interface SilenceTableItem extends Silence { type SilenceTableColumnProps = DynamicTableColumnProps; type SilenceTableItemProps = DynamicTableItemProps; interface Props { - silences: Silence[]; - alertManagerAlerts: AlertmanagerAlert[]; alertManagerSourceName: string; } -const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: Props) => { +const SilencesTable = ({ alertManagerSourceName }: Props) => { + const [getAmAlerts, { data: alertManagerAlerts, isLoading: amAlertsIsLoading }] = + alertmanagerApi.endpoints.getAlertmanagerAlerts.useLazyQuery({ pollingInterval: SILENCES_POLL_INTERVAL_MS }); + const [getSilences, { data: silences = [], isLoading, error }] = alertSilencesApi.endpoints.getSilences.useLazyQuery({ + pollingInterval: SILENCES_POLL_INTERVAL_MS, + }); + + const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery( + { amSourceName: alertManagerSourceName ?? '' }, + { skip: !alertManagerSourceName } + ); + + const mimirLazyInitError = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any)?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; + + useEffect(() => { + getSilences({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName) }); + getAmAlerts({ amSourceName: alertManagerSourceName }); + }, [alertManagerSourceName, getAmAlerts, getSilences]); + const styles = useStyles2(getStyles); const [queryParams] = useQueryParams(); const filteredSilencesNotExpired = useFilteredSilences(silences, false); @@ -45,7 +66,7 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: const itemsNotExpired = useMemo((): SilenceTableItemProps[] => { const findSilencedAlerts = (id: string) => { - return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); + return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id)); }; return filteredSilencesNotExpired.map((silence) => { const silencedAlerts = findSilencedAlerts(silence.id); @@ -58,7 +79,7 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: const itemsExpired = useMemo((): SilenceTableItemProps[] => { const findSilencedAlerts = (id: string) => { - return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); + return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id)); }; return filteredSilencesExpired.map((silence) => { const silencedAlerts = findSilencedAlerts(silence.id); @@ -69,6 +90,29 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }: }); }, [filteredSilencesExpired, alertManagerAlerts]); + if (isLoading || amAlertsIsLoading) { + return null; + } + + if (mimirLazyInitError) { + return ( + + Create a new contact point to create a configuration using the default values or contact your administrator to + set up the Alertmanager. + + ); + } + + if (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errMessage = (error as any)?.message || 'Unknown error.'; + return ( + + {errMessage} + + ); + } + return (
{!!silences.length && ( @@ -169,43 +213,43 @@ const useFilteredSilences = (silences: Silence[], expired = false) => { }; const getStyles = (theme: GrafanaTheme2) => ({ - topButtonContainer: css` - display: flex; - flex-direction: row; - justify-content: flex-end; - `, - addNewSilence: css` - margin: ${theme.spacing(2, 0)}; - `, - callout: css` - background-color: ${theme.colors.background.secondary}; - border-top: 3px solid ${theme.colors.info.border}; - border-radius: ${theme.shape.radius.default}; - height: 62px; - display: flex; - flex-direction: row; - align-items: center; + topButtonContainer: css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }), + addNewSilence: css({ + margin: theme.spacing(2, 0), + }), + callout: css({ + backgroundColor: theme.colors.background.secondary, + borderTop: `3px solid ${theme.colors.info.border}`, + borderRadius: theme.shape.radius.default, + height: '62px', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', - & > * { - margin-left: ${theme.spacing(1)}; - } - `, - calloutIcon: css` - color: ${theme.colors.info.text}; - `, - editButton: css` - margin-left: ${theme.spacing(0.5)}; - `, + '& > *': { + marginLeft: theme.spacing(1), + }, + }), + calloutIcon: css({ + color: theme.colors.info.text, + }), + editButton: css({ + marginLeft: theme.spacing(0.5), + }), }); function useColumns(alertManagerSourceName: string) { - const dispatch = useDispatch(); const styles = useStyles2(getStyles); const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence); + const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation(); return useMemo((): SilenceTableColumnProps[] => { - const handleExpireSilenceClick = (id: string) => { - dispatch(expireSilenceAction(alertManagerSourceName, id)); + const handleExpireSilenceClick = (silenceId: string) => { + expireSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), silenceId }); }; const columns: SilenceTableColumnProps[] = [ { @@ -281,6 +325,6 @@ function useColumns(alertManagerSourceName: string) { }); } return columns; - }, [alertManagerSourceName, dispatch, styles.editButton, updateAllowed, updateSupported]); + }, [alertManagerSourceName, expireSilence, styles.editButton, updateAllowed, updateSupported]); } export default SilencesTable; From 341449f4f52745f6084dbacc42edbbeacba1c78e Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 25 Apr 2024 16:36:45 +0100 Subject: [PATCH 10/53] Remove unused actions/reducers --- .../alerting/unified/api/alertmanager.ts | 36 ----------- .../alerting/unified/state/actions.ts | 59 ------------------- .../alerting/unified/state/reducers.ts | 8 --- 3 files changed, 103 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 27d080a6c4a0b..3d5bee320a41c 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -11,8 +11,6 @@ import { ExternalAlertmanagersResponse, Matcher, Receiver, - Silence, - SilenceCreatePayload, TestReceiversAlert, TestReceiversPayload, TestReceiversResult, @@ -79,40 +77,6 @@ export async function deleteAlertManagerConfig(alertManagerSourceName: string): ); } -export async function fetchSilences(alertManagerSourceName: string): Promise { - const result = await lastValueFrom( - getBackendSrv().fetch({ - url: `/api/alertmanager/${getDatasourceAPIUid(alertManagerSourceName)}/api/v2/silences`, - showErrorAlert: false, - showSuccessAlert: false, - }) - ); - return result.data; -} - -// returns the new silence ID. Even in the case of an update, a new silence is created and the previous one expired. -export async function createOrUpdateSilence( - alertmanagerSourceName: string, - payload: SilenceCreatePayload -): Promise { - const result = await lastValueFrom( - getBackendSrv().fetch({ - url: `/api/alertmanager/${getDatasourceAPIUid(alertmanagerSourceName)}/api/v2/silences`, - data: payload, - showErrorAlert: false, - showSuccessAlert: false, - method: 'POST', - }) - ); - return result.data; -} - -export async function expireSilence(alertmanagerSourceName: string, silenceID: string): Promise { - await getBackendSrv().delete( - `/api/alertmanager/${getDatasourceAPIUid(alertmanagerSourceName)}/api/v2/silence/${encodeURIComponent(silenceID)}` - ); -} - export async function fetchAlerts( alertmanagerSourceName: string, matchers?: Matcher[], diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index caf8d9db0a007..d362b7282b977 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -4,15 +4,12 @@ import { isEmpty } from 'lodash'; import { locationService } from '@grafana/runtime'; import { logMeasurement } from '@grafana/runtime/src/utils/logging'; import { - AlertmanagerAlert, AlertManagerCortexConfig, AlertmanagerGroup, ExternalAlertmanagerConfig, ExternalAlertmanagersResponse, Matcher, Receiver, - Silence, - SilenceCreatePayload, TestReceiversAlert, } from 'app/plugins/datasource/alertmanager/types'; import { FolderDTO, NotifierDTO, StoreState, ThunkResult } from 'app/types'; @@ -45,14 +42,10 @@ import { } from '../Analytics'; import { addAlertManagers, - createOrUpdateSilence, deleteAlertManagerConfig, - expireSilence, fetchAlertGroups, - fetchAlerts, fetchExternalAlertmanagerConfig, fetchExternalAlertmanagers, - fetchSilences, testReceivers, updateAlertManagerConfig, } from '../api/alertmanager'; @@ -204,17 +197,6 @@ export function fetchPromAndRulerRulesAction({ }; } -export const fetchSilencesAction = createAsyncThunk( - 'unifiedalerting/fetchSilences', - (alertManagerSourceName: string): Promise => { - const fetchSilencesWithLogging = withPerformanceLogging('unifiedalerting/fetchSilences', fetchSilences, { - dataSourceName: alertManagerSourceName, - }); - - return withSerializedError(fetchSilencesWithLogging(alertManagerSourceName)); - } -); - // this will only trigger ruler rules fetch if rules are not loaded yet and request is not in flight export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkResult { return (dispatch, getStore) => { @@ -561,47 +543,6 @@ export const updateAlertManagerConfigAction = createAsyncThunk => - withSerializedError(fetchAlerts(alertManagerSourceName, [], true, true, true)) -); - -export const expireSilenceAction = (alertManagerSourceName: string, silenceId: string): ThunkResult => { - return async (dispatch) => { - await withAppEvents(expireSilence(alertManagerSourceName, silenceId), { - successMessage: 'Silence expired.', - }); - dispatch(fetchSilencesAction(alertManagerSourceName)); - dispatch(fetchAmAlertsAction(alertManagerSourceName)); - }; -}; - -type UpdateSilenceActionOptions = { - alertManagerSourceName: string; - payload: SilenceCreatePayload; - exitOnSave: boolean; - successMessage?: string; -}; - -export const createOrUpdateSilenceAction = createAsyncThunk( - 'unifiedalerting/updateSilence', - ({ alertManagerSourceName, payload, exitOnSave, successMessage }): Promise => - withAppEvents( - withSerializedError( - (async () => { - await createOrUpdateSilence(alertManagerSourceName, payload); - if (exitOnSave) { - locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName)); - } - })() - ), - { - successMessage, - } - ) -); - export const deleteReceiverAction = (receiverName: string, alertManagerSourceName: string): ThunkResult => { return async (dispatch) => { const config = await dispatch( diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index c6e2c1c531b7f..8783f4798779a 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -3,10 +3,8 @@ import { combineReducers } from 'redux'; import { createAsyncMapSlice, createAsyncSlice } from '../utils/redux'; import { - createOrUpdateSilenceAction, deleteAlertManagerConfigAction, fetchAlertGroupsAction, - fetchAmAlertsAction, fetchEditableRuleAction, fetchExternalAlertmanagersAction, fetchExternalAlertmanagersConfigAction, @@ -16,7 +14,6 @@ import { fetchPromRulesAction, fetchRulerRulesAction, fetchRulesSourceBuildInfoAction, - fetchSilencesAction, saveRuleFormAction, testReceiversAction, updateAlertManagerConfigAction, @@ -32,8 +29,6 @@ export const reducer = combineReducers({ promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) .reducer, - silences: createAsyncMapSlice('silences', fetchSilencesAction, (alertManagerSourceName) => alertManagerSourceName) - .reducer, ruleForm: combineReducers({ saveRule: createAsyncSlice('saveRule', saveRuleFormAction).reducer, existingRule: createAsyncSlice('existingRule', fetchEditableRuleAction).reducer, @@ -41,9 +36,6 @@ export const reducer = combineReducers({ grafanaNotifiers: createAsyncSlice('grafanaNotifiers', fetchGrafanaNotifiersAction).reducer, saveAMConfig: createAsyncSlice('saveAMConfig', updateAlertManagerConfigAction).reducer, deleteAMConfig: createAsyncSlice('deleteAMConfig', deleteAlertManagerConfigAction).reducer, - updateSilence: createAsyncSlice('updateSilence', createOrUpdateSilenceAction).reducer, - amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName) - .reducer, folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer, amAlertGroups: createAsyncMapSlice( 'amAlertGroups', From 10fcd541e36b918b6df3b4be07e86ebf90b99bfc Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 25 Apr 2024 16:36:57 +0100 Subject: [PATCH 11/53] Add handlers to mock server --- public/app/features/alerting/unified/mockApi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 3cce73e348e62..77bf8b968fe8b 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -5,6 +5,7 @@ import { setupServer, SetupServer } from 'msw/node'; import { DataSourceInstanceSettings, PluginMeta } from '@grafana/data'; import { setBackendSrv } from '@grafana/runtime'; import { AlertRuleUpdated } from 'app/features/alerting/unified/api/alertRuleApi'; +import allHandlers from 'app/features/alerting/unified/mocks/server/handlers'; import { DashboardDTO, FolderDTO, NotifierDTO, OrgUser } from 'app/types'; import { PromBuildInfoResponse, @@ -424,7 +425,7 @@ export function mockDashboardApi(server: SetupServer) { }; } -const server = setupServer(); +const server = setupServer(...allHandlers); // Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it export function setupMswServer() { From f3978300984ac0e887a9bbb08f03ebdab8510cf3 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 25 Apr 2024 16:37:05 +0100 Subject: [PATCH 12/53] Refactor Silences test --- .../alerting/unified/Silences.test.tsx | 228 ++++++------------ 1 file changed, 79 insertions(+), 149 deletions(-) diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 905ed88d476c1..92c50bf446e00 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -1,49 +1,35 @@ -import { render, waitFor } from '@testing-library/react'; -import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; import React from 'react'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render, waitFor, userEvent } from 'test/test-utils'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; import { dateTime } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; -import { contextSrv } from 'app/core/services/context_srv'; -import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; -import { SilenceState } from '../../../plugins/datasource/alertmanager/types'; - import Silences from './Silences'; -import { createOrUpdateSilence, fetchAlerts, fetchSilences } from './api/alertmanager'; -import { grantUserPermissions, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks'; +import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; import { AlertmanagerProvider } from './state/AlertmanagerContext'; import { setupDataSources } from './testSetup/datasources'; -import { parseMatchers } from './utils/alertmanager'; import { DataSourceType } from './utils/datasource'; -jest.mock('./api/alertmanager'); jest.mock('app/core/services/context_srv'); const TEST_TIMEOUT = 60000; -const mocks = { - api: { - fetchSilences: jest.mocked(fetchSilences), - fetchAlerts: jest.mocked(fetchAlerts), - createOrUpdateSilence: jest.mocked(createOrUpdateSilence), - }, - contextSrv: jest.mocked(contextSrv), -}; - const renderSilences = (location = '/alerting/silences/') => { locationService.push(location); - return render( - - - - - + + + , + { + routerOptions: { + initialEntries: [location], + }, + } ); }; @@ -57,7 +43,8 @@ const dataSources = { const ui = { notExpiredTable: byTestId('not-expired-table'), expiredTable: byTestId('expired-table'), - expiredCaret: byText(/expired/i), + expiredCaret: byText(/expired silences \(/i), + silencesTags: byLabelText(/tags/i), silenceRow: byTestId('row'), silencedAlertCell: byTestId('alerts'), addSilenceButton: byRole('link', { name: /add silence/i }), @@ -80,28 +67,6 @@ const ui = { const resetMocks = () => { jest.resetAllMocks(); - mocks.api.fetchSilences.mockImplementation(() => { - return Promise.resolve([ - mockSilence({ id: '12345' }), - mockSilence({ id: '67890', matchers: parseMatchers('foo!=bar'), comment: 'Catch all' }), - mockSilence({ id: '1111', status: { state: SilenceState.Expired } }), - ]); - }); - - mocks.api.fetchAlerts.mockImplementation(() => { - return Promise.resolve([ - mockAlertmanagerAlert({ - labels: { foo: 'bar' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - mockAlertmanagerAlert({ - labels: { foo: 'buzz' }, - status: { state: AlertState.Suppressed, silencedBy: ['67890'], inhibitedBy: [] }, - }), - ]); - }); - - mocks.api.createOrUpdateSilence.mockResolvedValue(mockSilence()); grantUserPermissions([ AccessControlAction.AlertingInstanceRead, @@ -117,6 +82,21 @@ const setUserLogged = (isLogged: boolean) => { config.bootData.user.name = isLogged ? 'admin' : ''; }; +const enterSilenceLabel = async (index: number, name: string, matcher: MatcherOperator, value: string) => { + const user = userEvent.setup(); + await user.type(ui.editor.matcherName.getAll()[index], name); + await user.type(ui.editor.matcherOperatorSelect.getAll()[index], matcher); + await user.tab(); + await user.type(ui.editor.matcherValue.getAll()[index], value); +}; + +const addAdditionalMatcher = async () => { + const user = userEvent.setup(); + await user.click(ui.editor.addMatcherButton.get()); +}; + +setupMswServer(); + describe('Silences', () => { beforeAll(resetMocks); afterEach(resetMocks); @@ -128,26 +108,29 @@ describe('Silences', () => { it( 'loads and shows silences', async () => { + const user = userEvent.setup(); renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - - await userEvent.click(ui.expiredCaret.get()); - expect(ui.notExpiredTable.get()).not.toBeNull(); - expect(ui.expiredTable.get()).not.toBeNull(); - let silences = ui.silenceRow.queryAll(); - expect(silences).toHaveLength(3); - expect(silences[0]).toHaveTextContent('foo=bar'); - expect(silences[1]).toHaveTextContent('foo!=bar'); - expect(silences[2]).toHaveTextContent('foo=bar'); - - await userEvent.click(ui.expiredCaret.getAll()[0]); - expect(ui.notExpiredTable.get()).not.toBeNull(); - expect(ui.expiredTable.query()).toBeNull(); - silences = ui.silenceRow.queryAll(); - expect(silences).toHaveLength(2); - expect(silences[0]).toHaveTextContent('foo=bar'); - expect(silences[1]).toHaveTextContent('foo!=bar'); + + expect(await ui.notExpiredTable.find()).toBeInTheDocument(); + + await user.click(ui.expiredCaret.get()); + expect(ui.expiredTable.get()).toBeInTheDocument(); + + const allSilences = ui.silenceRow.queryAll(); + expect(allSilences).toHaveLength(3); + expect(allSilences[0]).toHaveTextContent('foo=bar'); + expect(allSilences[1]).toHaveTextContent('foo!=bar'); + expect(allSilences[2]).toHaveTextContent('foo=bar'); + + await user.click(ui.expiredCaret.get()); + + expect(ui.notExpiredTable.get()).toBeInTheDocument(); + expect(ui.expiredTable.query()).not.toBeInTheDocument(); + + const activeSilences = ui.silenceRow.queryAll(); + expect(activeSilences).toHaveLength(2); + expect(activeSilences[0]).toHaveTextContent('foo=bar'); + expect(activeSilences[1]).toHaveTextContent('foo!=bar'); }, TEST_TIMEOUT ); @@ -155,25 +138,13 @@ describe('Silences', () => { it( 'shows the correct number of silenced alerts', async () => { - mocks.api.fetchAlerts.mockImplementation(() => { - return Promise.resolve([ - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - ]); - }); - renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - const silencedAlertRows = ui.silencedAlertCell.getAll(ui.notExpiredTable.get()); - expect(silencedAlertRows).toHaveLength(2); + const notExpiredTable = await ui.notExpiredTable.find(); + + expect(notExpiredTable).toBeInTheDocument(); + + const silencedAlertRows = await ui.silencedAlertCell.findAll(notExpiredTable); expect(silencedAlertRows[0]).toHaveTextContent('2'); expect(silencedAlertRows[1]).toHaveTextContent('0'); }, @@ -184,12 +155,9 @@ describe('Silences', () => { 'filters silences by matchers', async () => { renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - const queryBar = ui.queryBar.get(); - await userEvent.click(queryBar); - await userEvent.paste('foo=bar'); + const queryBar = await ui.queryBar.find(); + await userEvent.type(queryBar, 'foo=bar'); await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(2)); }, @@ -199,24 +167,23 @@ describe('Silences', () => { it('shows creating a silence button for users with access', async () => { renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); - - expect(ui.addSilenceButton.get()).toBeInTheDocument(); + expect(await ui.addSilenceButton.find()).toBeInTheDocument(); }); it('hides actions for creating a silence for users without access', async () => { grantUserPermissions([AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead]); renderSilences(); - await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + const notExpiredTable = await ui.notExpiredTable.find(); + + expect(notExpiredTable).toBeInTheDocument(); expect(ui.addSilenceButton.query()).not.toBeInTheDocument(); }); }); -describe('Silence edit', () => { +describe('Silence create/edit', () => { const baseUrlPath = '/alerting/silence/new'; beforeAll(resetMocks); afterEach(resetMocks); @@ -242,7 +209,7 @@ describe('Silence edit', () => { const matchersQueryString = matchersParams.map((matcher) => `matcher=${encodeURIComponent(matcher)}`).join('&'); renderSilences(`${baseUrlPath}?${matchersQueryString}`); - await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + expect(await ui.editor.durationField.find()).toBeInTheDocument(); const matchers = ui.editor.matchersField.queryAll(); expect(matchers).toHaveLength(4); @@ -270,7 +237,7 @@ describe('Silence edit', () => { 'creates a new silence', async () => { renderSilences(baseUrlPath); - await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); + expect(await ui.editor.durationField.find()).toBeInTheDocument(); const start = new Date(); const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); @@ -285,48 +252,20 @@ describe('Silence edit', () => { await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(startDateString)); await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(endDateString)); - await userEvent.type(ui.editor.matcherName.get(), 'foo'); - await userEvent.type(ui.editor.matcherOperatorSelect.get(), '='); - await userEvent.tab(); - await userEvent.type(ui.editor.matcherValue.get(), 'bar'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.editor.addMatcherButton.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.editor.matcherName.getAll()[1], 'bar'); - await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[1], '!='); - await userEvent.tab(); - await userEvent.type(ui.editor.matcherValue.getAll()[1], 'buzz'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.editor.addMatcherButton.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.editor.matcherName.getAll()[2], 'region'); - await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[2], '=~'); - await userEvent.tab(); - await userEvent.type(ui.editor.matcherValue.getAll()[2], 'us-west-.*'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.editor.addMatcherButton.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - await userEvent.type(ui.editor.matcherName.getAll()[3], 'env'); - await userEvent.type(ui.editor.matcherOperatorSelect.getAll()[3], '!~'); - await userEvent.tab(); - await userEvent.type(ui.editor.matcherValue.getAll()[3], 'dev|staging'); + await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar'); + + await addAdditionalMatcher(); + await enterSilenceLabel(1, 'bar', MatcherOperator.notEqual, 'buzz'); + + await addAdditionalMatcher(); + await enterSilenceLabel(2, 'region', MatcherOperator.regex, 'us-west-.*'); + + await addAdditionalMatcher(); + await enterSilenceLabel(3, 'env', MatcherOperator.notRegex, 'dev|staging'); await userEvent.click(ui.editor.submit.get()); - await waitFor(() => - expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( - 'grafana', - expect.objectContaining({ - comment: expect.stringMatching(/created (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/), - matchers: [ - { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, - { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, - { isEqual: true, isRegex: true, name: 'region', value: 'us-west-.*' }, - { isEqual: false, isRegex: true, name: 'env', value: 'dev|staging' }, - ], - }) - ) - ); + expect(await ui.notExpiredTable.find()).toBeInTheDocument(); }, TEST_TIMEOUT ); @@ -339,20 +278,11 @@ describe('Silence edit', () => { renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`); await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); - await user.type(ui.editor.matcherName.getAll()[0], 'foo'); - await user.type(ui.editor.matcherOperatorSelect.getAll()[0], '='); - await user.type(ui.editor.matcherValue.getAll()[0], 'bar'); + await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar'); await user.click(ui.editor.submit.get()); - await waitFor(() => - expect(mocks.api.createOrUpdateSilence).toHaveBeenCalledWith( - 'Alertmanager', - expect.objectContaining({ - matchers: [{ isEqual: true, isRegex: false, name: 'foo', value: 'bar' }], - }) - ) - ); + expect(await ui.notExpiredTable.find()).toBeInTheDocument(); expect(locationService.getSearch().get('alertmanager')).toBe('Alertmanager'); }, From cdfc6baea414adf01e0234081520e3a3996648ff Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 09:26:54 +0100 Subject: [PATCH 13/53] Remove unused `fetchAlerts` method --- .../alerting/unified/api/alertmanager.ts | 39 +------------------ 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 3d5bee320a41c..97fd5bfe321bd 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -1,15 +1,13 @@ import { lastValueFrom } from 'rxjs'; -import { isObject, urlUtil } from '@grafana/data'; +import { isObject } from '@grafana/data'; import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { - AlertmanagerAlert, AlertManagerCortexConfig, AlertmanagerGroup, AlertmanagerStatus, ExternalAlertmanagerConfig, ExternalAlertmanagersResponse, - Matcher, Receiver, TestReceiversAlert, TestReceiversPayload, @@ -77,37 +75,6 @@ export async function deleteAlertManagerConfig(alertManagerSourceName: string): ); } -export async function fetchAlerts( - alertmanagerSourceName: string, - matchers?: Matcher[], - silenced = true, - active = true, - inhibited = true -): Promise { - const filters = - urlUtil.toUrlParams({ silenced, active, inhibited }) + - matchers - ?.map( - (matcher) => - `filter=${encodeURIComponent( - `${escapeQuotes(matcher.name)}=${matcher.isRegex ? '~' : ''}"${escapeQuotes(matcher.value)}"` - )}` - ) - .join('&') || ''; - - const result = await lastValueFrom( - getBackendSrv().fetch({ - url: - `/api/alertmanager/${getDatasourceAPIUid(alertmanagerSourceName)}/api/v2/alerts` + - (filters ? '?' + filters : ''), - showErrorAlert: false, - showSuccessAlert: false, - }) - ); - - return result.data; -} - export async function fetchAlertGroups(alertmanagerSourceName: string): Promise { const result = await lastValueFrom( getBackendSrv().fetch({ @@ -234,7 +201,3 @@ export async function fetchExternalAlertmanagerConfig(): Promise Date: Fri, 26 Apr 2024 09:27:39 +0100 Subject: [PATCH 14/53] Add correct tag invalidation after creating a silence --- public/app/features/alerting/unified/api/alertSilencesApi.ts | 2 +- public/app/features/alerting/unified/api/alertingApi.ts | 3 ++- public/app/features/alerting/unified/api/alertmanagerApi.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertSilencesApi.ts b/public/app/features/alerting/unified/api/alertSilencesApi.ts index 7ecf53ceac175..9f8ba67363ce9 100644 --- a/public/app/features/alerting/unified/api/alertSilencesApi.ts +++ b/public/app/features/alerting/unified/api/alertSilencesApi.ts @@ -43,7 +43,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ method: 'POST', data: payload, }), - invalidatesTags: ['AlertSilences'], + invalidatesTags: ['AlertSilences', 'AlertmanagerAlerts'], }), expireSilence: build.mutation< diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 1ee809e24e3e1..30b2a169da887 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -35,12 +35,13 @@ export const alertingApi = createApi({ tagTypes: [ 'AlertmanagerChoice', 'AlertmanagerConfiguration', + 'AlertmanagerAlerts', + 'AlertSilences', 'OnCallIntegrations', 'OrgMigrationState', 'DataSourceSettings', 'GrafanaLabels', 'CombinedAlertRule', - 'AlertSilences', ], endpoints: () => ({}), }); diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index ea0f1e948d9b1..dc605c0bd6f54 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -78,6 +78,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ params, }; }, + providesTags: ['AlertmanagerAlerts'], }), getAlertmanagerAlertGroups: build.query({ From df5c62b8ad6aa041c0fadc98d124eb4b09269c0b Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 09:27:56 +0100 Subject: [PATCH 15/53] Misc tidy up --- .../components/silences/SilencesEditor.tsx | 2 +- .../components/silences/SilencesTable.tsx | 46 +++++++++---------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 1381d54e553f0..0eedd024c735f 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -195,7 +195,7 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => return ( -
+
{ - const [getAmAlerts, { data: alertManagerAlerts, isLoading: amAlertsIsLoading }] = - alertmanagerApi.endpoints.getAlertmanagerAlerts.useLazyQuery({ pollingInterval: SILENCES_POLL_INTERVAL_MS }); - const [getSilences, { data: silences = [], isLoading, error }] = alertSilencesApi.endpoints.getSilences.useLazyQuery({ - pollingInterval: SILENCES_POLL_INTERVAL_MS, - }); + const { data: alertManagerAlerts, isLoading: amAlertsIsLoading } = + alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery( + { amSourceName: alertManagerSourceName, filter: { silenced: true, active: true, inhibited: true } }, + { pollingInterval: SILENCES_POLL_INTERVAL_MS } + ); + + const { + data: silences = [], + isLoading, + error, + } = alertSilencesApi.endpoints.getSilences.useQuery( + { datasourceUid: getDatasourceAPIUid(alertManagerSourceName) }, + { + pollingInterval: SILENCES_POLL_INTERVAL_MS, + } + ); const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery( { amSourceName: alertManagerSourceName ?? '' }, @@ -51,11 +62,6 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (error as any)?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; - useEffect(() => { - getSilences({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName) }); - getAmAlerts({ amSourceName: alertManagerSourceName }); - }, [alertManagerSourceName, getAmAlerts, getSilences]); - const styles = useStyles2(getStyles); const [queryParams] = useQueryParams(); const filteredSilencesNotExpired = useFilteredSilences(silences, false); @@ -119,11 +125,11 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { -
+ Add Silence -
+
{ }; const getStyles = (theme: GrafanaTheme2) => ({ - topButtonContainer: css({ - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - }), addNewSilence: css({ margin: theme.spacing(2, 0), }), @@ -237,13 +238,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ calloutIcon: css({ color: theme.colors.info.text, }), - editButton: css({ - marginLeft: theme.spacing(0.5), - }), }); function useColumns(alertManagerSourceName: string) { - const styles = useStyles2(getStyles); const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence); const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation(); @@ -312,10 +309,9 @@ function useColumns(alertManagerSourceName: string) { )} {silence.status.state !== 'expired' && ( )} @@ -325,6 +321,6 @@ function useColumns(alertManagerSourceName: string) { }); } return columns; - }, [alertManagerSourceName, expireSilence, styles.editButton, updateAllowed, updateSupported]); + }, [alertManagerSourceName, expireSilence, updateAllowed, updateSupported]); } export default SilencesTable; From f419c9b53a67ab9e1c0870f80837e1f24e204d16 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 09:45:00 +0100 Subject: [PATCH 16/53] Add comment explaining reset in silences editor --- .../alerting/unified/components/silences/SilencesEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 0eedd024c735f..4a87736a46fab 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -139,6 +139,7 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => const matcherFields = watch('matchers'); useEffect(() => { + // Allows the form to correctly initialise when an existing silence is fetch from the backend reset(getDefaultFormValues(urlSearchParams, silence)); }, [reset, silence, urlSearchParams]); From 0dc003aadcacc0e85a45cba2cbe4e0b134c2e400 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 11:19:14 +0100 Subject: [PATCH 17/53] Split handlers out into separate files --- .../alerting/unified/mocks/alertmanagerApi.ts | 23 +++++++- .../alerting/unified/mocks/datasources.ts | 5 ++ .../unified/mocks/server/configure.ts | 2 +- .../alerting/unified/mocks/server/handlers.ts | 56 +++---------------- .../alerting/unified/mocks/silences.ts | 15 +++++ 5 files changed, 51 insertions(+), 50 deletions(-) create mode 100644 public/app/features/alerting/unified/mocks/datasources.ts create mode 100644 public/app/features/alerting/unified/mocks/silences.ts diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index 1db4862f414e5..b5ad61cc0b1e8 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -1,9 +1,12 @@ import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; +import { mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks'; + import { AlertmanagerChoice, AlertManagerCortexConfig, + AlertState, ExternalAlertmanagersResponse, } from '../../../../plugins/datasource/alertmanager/types'; import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi'; @@ -13,8 +16,12 @@ export const defaultAlertmanagerChoiceResponse: AlertmanagersChoiceResponse = { alertmanagersChoice: AlertmanagerChoice.Internal, numExternalAlertmanagers: 0, }; + +export const alertmanagerChoiceHandler = (response = defaultAlertmanagerChoiceResponse) => + http.get('/api/v1/ngalert', () => HttpResponse.json(response)); + export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) { - server.use(http.get('/api/v1/ngalert', () => HttpResponse.json(response))); + server.use(alertmanagerChoiceHandler(response)); } export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = { @@ -38,3 +45,17 @@ export function mockAlertmanagerConfigResponse( ) ); } + +export const alertmanagerAlertsListHandler = () => + http.get('/api/alertmanager/:datasourceUid/api/v2/alerts', () => + HttpResponse.json([ + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + mockAlertmanagerAlert({ + labels: { foo: 'bar', buzz: 'bazz' }, + status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + }), + ]) + ); diff --git a/public/app/features/alerting/unified/mocks/datasources.ts b/public/app/features/alerting/unified/mocks/datasources.ts new file mode 100644 index 0000000000000..0ff5d12bf65de --- /dev/null +++ b/public/app/features/alerting/unified/mocks/datasources.ts @@ -0,0 +1,5 @@ +import { HttpResponse, http } from 'msw'; + +// TODO: Add more accurate endpoint responses as tests require +export const datasourceBuildInfoHandler = () => + http.get('/api/datasources/proxy/uid/:datasourceUid/api/v1/status/buildinfo', () => HttpResponse.json({})); diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 27e3683061660..04b17f571ac97 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -1,5 +1,5 @@ import server from 'app/features/alerting/unified/mockApi'; -import { alertmanagerChoiceHandler } from 'app/features/alerting/unified/mocks/server/handlers'; +import { alertmanagerChoiceHandler } from 'app/features/alerting/unified/mocks/alertmanagerApi'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; /** diff --git a/public/app/features/alerting/unified/mocks/server/handlers.ts b/public/app/features/alerting/unified/mocks/server/handlers.ts index aea95b81cf5ee..78bc9baa86c36 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers.ts @@ -1,53 +1,13 @@ /** - * Contains definitions for all handlers that are required for test rendering of components within Alerting + * Contains all handlers that are required for test rendering of components within Alerting */ -import { HttpResponse, http } from 'msw'; - -import { mockAlertmanagerAlert, mockSilences } from 'app/features/alerting/unified/mocks'; -import { defaultAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi'; -import { AlertState } from 'app/plugins/datasource/alertmanager/types'; - -/////////////////// -// Alertmanagers // -/////////////////// - -export const alertmanagerChoiceHandler = (response = defaultAlertmanagerChoiceResponse) => - http.get('/api/v1/ngalert', () => HttpResponse.json(response)); - -const alertmanagerAlertsListHandler = () => - http.get('/api/alertmanager/:datasourceUid/api/v2/alerts', () => - HttpResponse.json([ - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - mockAlertmanagerAlert({ - labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, - }), - ]) - ); - -///////////////// -// Datasources // -///////////////// - -// TODO: Add more accurate endpoint responses as tests require -const datasourceBuildInfoHandler = () => - http.get('/api/datasources/proxy/uid/:datasourceUid/api/v1/status/buildinfo', () => HttpResponse.json({})); - -////////////// -// Silences // -////////////// - -const silencesListHandler = (silences = mockSilences) => - http.get('/api/alertmanager/:datasourceUid/api/v2/silences', () => HttpResponse.json(silences)); - -const createSilenceHandler = () => - http.post('/api/alertmanager/:datasourceUid/api/v2/silences', () => - HttpResponse.json({ silenceId: '4bda5b38-7939-4887-9ec2-16323b8e3b4e' }) - ); +import { + alertmanagerAlertsListHandler, + alertmanagerChoiceHandler, +} from 'app/features/alerting/unified/mocks/alertmanagerApi'; +import { datasourceBuildInfoHandler } from 'app/features/alerting/unified/mocks/datasources'; +import { silenceCreateHandler, silencesListHandler } from 'app/features/alerting/unified/mocks/silences'; /** * All mock handlers that are required across Alerting tests @@ -55,7 +15,7 @@ const createSilenceHandler = () => const allHandlers = [ alertmanagerChoiceHandler(), silencesListHandler(), - createSilenceHandler(), + silenceCreateHandler(), alertmanagerAlertsListHandler(), datasourceBuildInfoHandler(), ]; diff --git a/public/app/features/alerting/unified/mocks/silences.ts b/public/app/features/alerting/unified/mocks/silences.ts new file mode 100644 index 0000000000000..e23e6ea9dedb6 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/silences.ts @@ -0,0 +1,15 @@ +import { HttpResponse, http } from 'msw'; + +import { mockSilences } from 'app/features/alerting/unified/mocks'; + +////////////// +// Silences // +////////////// + +export const silencesListHandler = (silences = mockSilences) => + http.get('/api/alertmanager/:datasourceUid/api/v2/silences', () => HttpResponse.json(silences)); + +export const silenceCreateHandler = () => + http.post('/api/alertmanager/:datasourceUid/api/v2/silences', () => + HttpResponse.json({ silenceId: '4bda5b38-7939-4887-9ec2-16323b8e3b4e' }) + ); From 4d4bf391846492911cc0ecc22c0c6fda146846d6 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 11:20:08 +0100 Subject: [PATCH 18/53] Check for server received body This is not ideal! Preference would be to have a more robust mock server that responds to the received silence and appends it to a stateful list for the test and then resets afterwards --- .../alerting/unified/Silences.test.tsx | 28 +++++++++++++++++++ .../alerting/unified/mocks/server/events.ts | 23 +++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 public/app/features/alerting/unified/mocks/server/events.ts diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 92c50bf446e00..0ac440359d551 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -6,6 +6,8 @@ import { dateTime } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { waitForServerRequest } from 'app/features/alerting/unified/mocks/server/events'; +import { silenceCreateHandler } from 'app/features/alerting/unified/mocks/silences'; import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; @@ -239,6 +241,8 @@ describe('Silence create/edit', () => { renderSilences(baseUrlPath); expect(await ui.editor.durationField.find()).toBeInTheDocument(); + const postRequest = waitForServerRequest(silenceCreateHandler()); + const start = new Date(); const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); @@ -266,6 +270,20 @@ describe('Silence create/edit', () => { await userEvent.click(ui.editor.submit.get()); expect(await ui.notExpiredTable.find()).toBeInTheDocument(); + + const createSilenceRequest = await postRequest; + const requestBody = await createSilenceRequest.clone().json(); + expect(requestBody).toMatchObject( + expect.objectContaining({ + comment: expect.stringMatching(/created (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/), + matchers: [ + { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, + { isEqual: false, isRegex: false, name: 'bar', value: 'buzz' }, + { isEqual: true, isRegex: true, name: 'region', value: 'us-west-.*' }, + { isEqual: false, isRegex: true, name: 'env', value: 'dev|staging' }, + ], + }) + ); }, TEST_TIMEOUT ); @@ -275,6 +293,8 @@ describe('Silence create/edit', () => { async () => { const user = userEvent.setup(); + const postRequest = waitForServerRequest(silenceCreateHandler()); + renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`); await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull()); @@ -285,6 +305,14 @@ describe('Silence create/edit', () => { expect(await ui.notExpiredTable.find()).toBeInTheDocument(); expect(locationService.getSearch().get('alertmanager')).toBe('Alertmanager'); + + const createSilenceRequest = await postRequest; + const requestBody = await createSilenceRequest.clone().json(); + expect(requestBody).toMatchObject( + expect.objectContaining({ + matchers: [{ isEqual: true, isRegex: false, name: 'foo', value: 'bar' }], + }) + ); }, TEST_TIMEOUT ); diff --git a/public/app/features/alerting/unified/mocks/server/events.ts b/public/app/features/alerting/unified/mocks/server/events.ts new file mode 100644 index 0000000000000..1feb57d5a89cc --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/events.ts @@ -0,0 +1,23 @@ +import { HttpHandler, matchRequestUrl } from 'msw'; + +import server from 'app/features/alerting/unified/mockApi'; + +/** + * Wait for the mock server to receive a request for the given method + url combination, + * and resolve with information about the request that was made + * + * @deprecated Try not to use this 🙏 instead aim to assert against UI side effects + */ +export function waitForServerRequest(handler: HttpHandler) { + const { method, path } = handler.info; + return new Promise((resolve) => { + server.events.on('request:match', ({ request }) => { + const matchesMethod = request.method.toLowerCase() === String(method).toLowerCase(); + const matchesUrl = matchRequestUrl(new URL(request.url), path); + + if (matchesMethod && matchesUrl) { + resolve(request); + } + }); + }); +} From 9bf2cf0a52cefa09c08579e4f60206ef961b1108 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 11:27:18 +0100 Subject: [PATCH 19/53] Rename silences RTKQ tag --- .../app/features/alerting/unified/api/alertSilencesApi.ts | 8 ++++---- public/app/features/alerting/unified/api/alertingApi.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertSilencesApi.ts b/public/app/features/alerting/unified/api/alertSilencesApi.ts index 9f8ba67363ce9..a89686243c93d 100644 --- a/public/app/features/alerting/unified/api/alertSilencesApi.ts +++ b/public/app/features/alerting/unified/api/alertSilencesApi.ts @@ -13,7 +13,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ query: ({ datasourceUid }) => ({ url: `/api/alertmanager/${datasourceUid}/api/v2/silences`, }), - providesTags: ['AlertSilences'], + providesTags: ['AlertmanagerSilences'], }), getSilence: build.query< @@ -26,7 +26,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ query: ({ datasourceUid, id }) => ({ url: `/api/alertmanager/${datasourceUid}/api/v2/silence/${id}`, }), - providesTags: ['AlertSilences'], + providesTags: ['AlertmanagerSilences'], }), createSilence: build.mutation< @@ -43,7 +43,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ method: 'POST', data: payload, }), - invalidatesTags: ['AlertSilences', 'AlertmanagerAlerts'], + invalidatesTags: ['AlertmanagerSilences', 'AlertmanagerAlerts'], }), expireSilence: build.mutation< @@ -59,7 +59,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ url: `/api/alertmanager/${datasourceUid}/api/v2/silence/${silenceId}`, method: 'DELETE', }), - invalidatesTags: ['AlertSilences'], + invalidatesTags: ['AlertmanagerSilences'], }), }), }); diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index 30b2a169da887..bb1dd3071d440 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -36,7 +36,7 @@ export const alertingApi = createApi({ 'AlertmanagerChoice', 'AlertmanagerConfiguration', 'AlertmanagerAlerts', - 'AlertSilences', + 'AlertmanagerSilences', 'OnCallIntegrations', 'OrgMigrationState', 'DataSourceSettings', From a0373c66c3e6ad648bff0858119ffa7ad13b6e1f Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 11:27:31 +0100 Subject: [PATCH 20/53] Add fallback for alertmanager alerts --- .../alerting/unified/components/silences/SilencesTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 5b420dc7ea568..ba5691f538971 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -36,7 +36,7 @@ interface Props { } const SilencesTable = ({ alertManagerSourceName }: Props) => { - const { data: alertManagerAlerts, isLoading: amAlertsIsLoading } = + const { data: alertManagerAlerts = [], isLoading: amAlertsIsLoading } = alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery( { amSourceName: alertManagerSourceName, filter: { silenced: true, active: true, inhibited: true } }, { pollingInterval: SILENCES_POLL_INTERVAL_MS } @@ -72,7 +72,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { const itemsNotExpired = useMemo((): SilenceTableItemProps[] => { const findSilencedAlerts = (id: string) => { - return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id)); + return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); }; return filteredSilencesNotExpired.map((silence) => { const silencedAlerts = findSilencedAlerts(silence.id); @@ -85,7 +85,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { const itemsExpired = useMemo((): SilenceTableItemProps[] => { const findSilencedAlerts = (id: string) => { - return (alertManagerAlerts || []).filter((alert) => alert.status.silencedBy.includes(id)); + return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id)); }; return filteredSilencesExpired.map((silence) => { const silencedAlerts = findSilencedAlerts(silence.id); From 4fb1ac2bec1e85db9f16757255280ce269380bd7 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 11:29:47 +0100 Subject: [PATCH 21/53] Check error state more reliably --- .betterer.results | 4 ---- .../unified/components/silences/SilencesTable.tsx | 9 ++++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.betterer.results b/.betterer.results index 66562bf277d96..7bd18516f5a92 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2158,10 +2158,6 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"] ], - "public/app/features/alerting/unified/components/silences/SilencesTable.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] - ], "public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index ba5691f538971..d2d63114ed9b3 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { dateMath, GrafanaTheme2 } from '@grafana/data'; +import { isFetchError } from '@grafana/runtime'; import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack, Alert } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi'; @@ -59,8 +60,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { ); const mimirLazyInitError = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any)?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; + isFetchError(error) && error?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; const styles = useStyles2(getStyles); const [queryParams] = useQueryParams(); @@ -109,9 +109,8 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { ); } - if (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errMessage = (error as any)?.message || 'Unknown error.'; + if (isFetchError(error)) { + const errMessage = error?.message || 'Unknown error.'; return ( {errMessage} From 9860117399a068e693912547643a1608547e4cc7 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 15:42:41 +0100 Subject: [PATCH 22/53] Add more fine grained tag invalidation/providing for Silences --- .../app/features/alerting/unified/api/alertSilencesApi.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/public/app/features/alerting/unified/api/alertSilencesApi.ts b/public/app/features/alerting/unified/api/alertSilencesApi.ts index a89686243c93d..ad59bf29176f3 100644 --- a/public/app/features/alerting/unified/api/alertSilencesApi.ts +++ b/public/app/features/alerting/unified/api/alertSilencesApi.ts @@ -13,7 +13,8 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ query: ({ datasourceUid }) => ({ url: `/api/alertmanager/${datasourceUid}/api/v2/silences`, }), - providesTags: ['AlertmanagerSilences'], + providesTags: (result) => + result ? result.map(({ id }) => ({ type: 'AlertmanagerSilences', id })) : ['AlertmanagerSilences'], }), getSilence: build.query< @@ -25,8 +26,10 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ >({ query: ({ datasourceUid, id }) => ({ url: `/api/alertmanager/${datasourceUid}/api/v2/silence/${id}`, + showErrorAlert: false, }), - providesTags: ['AlertmanagerSilences'], + providesTags: (result, error, { id }) => + result ? [{ type: 'AlertmanagerSilences', id }] : ['AlertmanagerSilences'], }), createSilence: build.mutation< From a34c02fcf2ae2bbb58667ff74596f53a71171f17 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 15:45:34 +0100 Subject: [PATCH 23/53] Address misc PR feedback --- .../alerting/unified/Silences.test.tsx | 12 +++--- .../components/silences/SilencesEditor.tsx | 39 ++++++++++++++----- .../components/silences/SilencesTable.tsx | 19 ++++----- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 0ac440359d551..0d9d0a44f1d58 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -28,7 +28,7 @@ const renderSilences = (location = '/alerting/silences/') => { , { - routerOptions: { + historyOptions: { initialEntries: [location], }, } @@ -156,10 +156,11 @@ describe('Silences', () => { it( 'filters silences by matchers', async () => { + const user = userEvent.setup(); renderSilences(); const queryBar = await ui.queryBar.find(); - await userEvent.type(queryBar, 'foo=bar'); + await user.type(queryBar, 'foo=bar'); await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(2)); }, @@ -238,6 +239,7 @@ describe('Silence create/edit', () => { it( 'creates a new silence', async () => { + const user = userEvent.setup(); renderSilences(baseUrlPath); expect(await ui.editor.durationField.find()).toBeInTheDocument(); @@ -249,8 +251,8 @@ describe('Silence create/edit', () => { const startDateString = dateTime(start).format('YYYY-MM-DD'); const endDateString = dateTime(end).format('YYYY-MM-DD'); - await userEvent.clear(ui.editor.durationInput.get()); - await userEvent.type(ui.editor.durationInput.get(), '1d'); + await user.clear(ui.editor.durationInput.get()); + await user.type(ui.editor.durationInput.get(), '1d'); await waitFor(() => expect(ui.editor.durationInput.query()).toHaveValue('1d')); await waitFor(() => expect(ui.editor.timeRange.get()).toHaveTextContent(startDateString)); @@ -267,7 +269,7 @@ describe('Silence create/edit', () => { await addAdditionalMatcher(); await enterSilenceLabel(3, 'env', MatcherOperator.notRegex, 'dev|staging'); - await userEvent.click(ui.editor.submit.get()); + await user.click(ui.editor.submit.get()); expect(await ui.notExpiredTable.find()).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 4a87736a46fab..1bad2e84fa62f 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -2,7 +2,6 @@ import { css, cx } from '@emotion/css'; import { isEqual, pickBy } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useHistory } from 'react-router'; import { useDebounce } from 'react-use'; import { @@ -14,8 +13,18 @@ import { isValidDate, parseDuration, } from '@grafana/data'; -import { config } from '@grafana/runtime'; -import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui'; +import { config, isFetchError, locationService } from '@grafana/runtime'; +import { + Alert, + Button, + Field, + FieldSet, + Input, + LinkButton, + LoadingPlaceholder, + TextArea, + useStyles2, +} from '@grafana/ui'; import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi'; import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource'; import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; @@ -96,8 +105,9 @@ const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence): }; export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => { - const history = useHistory(); - const [getSilence, { data: silence, isLoading: getSilenceIsLoading }] = + // Use a lazy query to fetch the Silence info, as we may not always require this + // (e.g. if creating a new one from scratch, we don't need to fetch anything) + const [getSilence, { data: silence, isLoading: getSilenceIsLoading, error: errorGettingExistingSilence }] = alertSilencesApi.endpoints.getSilence.useLazyQuery(); const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation(); const [urlSearchParams] = useURLSearchParams(); @@ -129,7 +139,7 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => await createSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), payload }) .unwrap() .then(() => { - history.push(makeAMLink('/alerting/silences', alertManagerSourceName)); + locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName)); }); }; @@ -139,8 +149,10 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => const matcherFields = watch('matchers'); useEffect(() => { - // Allows the form to correctly initialise when an existing silence is fetch from the backend - reset(getDefaultFormValues(urlSearchParams, silence)); + if (silence) { + // Allows the form to correctly initialise when an existing silence is fetch from the backend + reset(getDefaultFormValues(urlSearchParams, silence)); + } }, [reset, silence, urlSearchParams]); useEffect(() => { @@ -190,13 +202,20 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name); if (getSilenceIsLoading) { - return null; + return ; + } + + const existingSilenceNotFound = + isFetchError(errorGettingExistingSilence) && errorGettingExistingSilence.status === 404; + + if (existingSilenceNotFound) { + return ; } return ( -
+
{ const { data: alertManagerAlerts = [], isLoading: amAlertsIsLoading } = alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery( { amSourceName: alertManagerSourceName, filter: { silenced: true, active: true, inhibited: true } }, - { pollingInterval: SILENCES_POLL_INTERVAL_MS } + API_QUERY_OPTIONS ); const { @@ -49,9 +51,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { error, } = alertSilencesApi.endpoints.getSilences.useQuery( { datasourceUid: getDatasourceAPIUid(alertManagerSourceName) }, - { - pollingInterval: SILENCES_POLL_INTERVAL_MS, - } + API_QUERY_OPTIONS ); const { currentData: amFeatures } = featureDiscoveryApi.useDiscoverAmFeaturesQuery( @@ -60,7 +60,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { ); const mimirLazyInitError = - isFetchError(error) && error?.message?.includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; + stringifyErrorLike(error).includes('the Alertmanager is not configured') && amFeatures?.lazyConfigInit; const styles = useStyles2(getStyles); const [queryParams] = useQueryParams(); @@ -97,7 +97,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { }, [filteredSilencesExpired, alertManagerAlerts]); if (isLoading || amAlertsIsLoading) { - return null; + return ; } if (mimirLazyInitError) { @@ -218,9 +218,6 @@ const useFilteredSilences = (silences: Silence[], expired = false) => { }; const getStyles = (theme: GrafanaTheme2) => ({ - addNewSilence: css({ - margin: theme.spacing(2, 0), - }), callout: css({ backgroundColor: theme.colors.background.secondary, borderTop: `3px solid ${theme.colors.info.border}`, From 31231cf5bf5c966f468b000827687d41bef62f05 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 15:45:48 +0100 Subject: [PATCH 24/53] Change test render method to use locationService --- public/test/test-utils.tsx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/public/test/test-utils.tsx b/public/test/test-utils.tsx index b27a0666b5476..ee6d2b6c2e731 100644 --- a/public/test/test-utils.tsx +++ b/public/test/test-utils.tsx @@ -1,12 +1,14 @@ import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; import { render, RenderOptions } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React, { ComponentProps, Fragment, PropsWithChildren } from 'react'; +import { createMemoryHistory, MemoryHistoryBuildOptions } from 'history'; +import React, { Fragment, PropsWithChildren } from 'react'; import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; +import { Router } from 'react-router-dom'; import { PreloadedState } from 'redux'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; +import { HistoryWrapper, setLocationService } from '@grafana/runtime'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider'; import { configureStore } from 'app/store/configureStore'; @@ -29,9 +31,9 @@ interface ExtendedRenderOptions extends RenderOptions { */ renderWithRouter?: boolean; /** - * Props to pass to `MemoryRouter`, if being used + * Props to pass to `createMemoryHistory`, if being used */ - routerOptions?: ComponentProps; + historyOptions?: MemoryHistoryBuildOptions; } /** @@ -41,7 +43,7 @@ interface ExtendedRenderOptions extends RenderOptions { const getWrapper = ({ store, renderWithRouter, - routerOptions, + historyOptions, grafanaContext, }: ExtendedRenderOptions & { grafanaContext?: GrafanaContextType; @@ -50,7 +52,13 @@ const getWrapper = ({ /** * Conditional router - either a MemoryRouter or just a Fragment */ - const PotentialRouter = renderWithRouter ? MemoryRouter : Fragment; + const PotentialRouter = renderWithRouter ? Router : Fragment; + + // Create a fresh location service for each test - otherwise we run the risk + // of it being stateful in between runs + const history = createMemoryHistory(historyOptions); + const locationService = new HistoryWrapper(history); + setLocationService(locationService); const context = { ...getGrafanaContextMock(), @@ -65,7 +73,7 @@ const getWrapper = ({ return ( - + {children} From fd07b3d4311e0966a3fc3ab35b7df57b6803fbd0 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 16:01:49 +0100 Subject: [PATCH 25/53] Link silence test handlers together more clearly and test more cases --- .../alerting/unified/Silences.test.tsx | 19 ++++++++++++++++++- public/app/features/alerting/unified/mocks.ts | 12 +++++++++--- .../alerting/unified/mocks/alertmanagerApi.ts | 6 +++--- .../alerting/unified/mocks/server/handlers.ts | 11 +++++++++-- .../alerting/unified/mocks/silences.ts | 11 +++++++++++ 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 0d9d0a44f1d58..84c35a27d93e4 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -12,7 +12,7 @@ import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import Silences from './Silences'; -import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks'; +import { grantUserPermissions, MOCK_SILENCE_ID_EXISTING, mockDataSource, MockDataSourceSrv } from './mocks'; import { AlertmanagerProvider } from './state/AlertmanagerContext'; import { setupDataSources } from './testSetup/datasources'; import { DataSourceType } from './utils/datasource'; @@ -51,6 +51,7 @@ const ui = { silencedAlertCell: byTestId('alerts'), addSilenceButton: byRole('link', { name: /add silence/i }), queryBar: byPlaceholderText('Search'), + existingSilenceNotFound: byRole('alert', { name: /existing silence .* not found/i }), editor: { timeRange: byTestId(selectors.components.TimePicker.openButton), durationField: byLabelText('Duration'), @@ -290,6 +291,22 @@ describe('Silence create/edit', () => { TEST_TIMEOUT ); + it('shows an error when existing silence cannot be found', async () => { + renderSilences('/alerting/silence/foo-bar/edit'); + + expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument(); + }); + + it('populates form with existing silence information', async () => { + renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`); + + // Await the first value to be populated, after which we can expect that all of the other + // existing fields have been filled out as well + await waitFor(() => expect(ui.editor.matcherName.get()).toHaveValue('foo')); + expect(ui.editor.matcherValue.get()).toHaveValue('bar'); + expect(ui.editor.comment.get()).toHaveValue('Silence noisy alerts'); + }); + it( 'silences page should contain alertmanager parameter after creating a silence', async () => { diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 978cf70a85931..7ac3c3dfb137f 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -307,10 +307,16 @@ export const mockSilence = (partial: Partial = {}): Silence => { }; }; +export const MOCK_SILENCE_ID_EXISTING = 'f209e273-0e4e-434f-9f66-e72f092025a2'; + export const mockSilences = [ - mockSilence({ id: '12345' }), - mockSilence({ id: '67890', matchers: parseMatchers('foo!=bar'), comment: 'Catch all' }), - mockSilence({ id: '1111', status: { state: SilenceState.Expired } }), + mockSilence({ id: MOCK_SILENCE_ID_EXISTING }), + mockSilence({ + id: 'ce031625-61c7-47cd-9beb-8760bccf0ed7', + matchers: parseMatchers('foo!=bar'), + comment: 'Catch all', + }), + mockSilence({ id: '145884a8-ee20-4864-9f84-661305fb7d82', status: { state: SilenceState.Expired } }), ]; export const mockNotifiersState = (partial: Partial = {}): NotifiersState => { diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index b5ad61cc0b1e8..9c30d8ef69902 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -1,7 +1,7 @@ import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; -import { mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks'; +import { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks'; import { AlertmanagerChoice, @@ -51,11 +51,11 @@ export const alertmanagerAlertsListHandler = () => HttpResponse.json([ mockAlertmanagerAlert({ labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + status: { state: AlertState.Suppressed, silencedBy: [MOCK_SILENCE_ID_EXISTING], inhibitedBy: [] }, }), mockAlertmanagerAlert({ labels: { foo: 'bar', buzz: 'bazz' }, - status: { state: AlertState.Suppressed, silencedBy: ['12345'], inhibitedBy: [] }, + status: { state: AlertState.Suppressed, silencedBy: [MOCK_SILENCE_ID_EXISTING], inhibitedBy: [] }, }), ]) ); diff --git a/public/app/features/alerting/unified/mocks/server/handlers.ts b/public/app/features/alerting/unified/mocks/server/handlers.ts index 78bc9baa86c36..7fa5949fdc7dc 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers.ts @@ -7,16 +7,23 @@ import { alertmanagerChoiceHandler, } from 'app/features/alerting/unified/mocks/alertmanagerApi'; import { datasourceBuildInfoHandler } from 'app/features/alerting/unified/mocks/datasources'; -import { silenceCreateHandler, silencesListHandler } from 'app/features/alerting/unified/mocks/silences'; +import { + silenceCreateHandler, + silenceGetHandler, + silencesListHandler, +} from 'app/features/alerting/unified/mocks/silences'; /** * All mock handlers that are required across Alerting tests */ const allHandlers = [ alertmanagerChoiceHandler(), + alertmanagerAlertsListHandler(), + silencesListHandler(), + silenceGetHandler(), silenceCreateHandler(), - alertmanagerAlertsListHandler(), + datasourceBuildInfoHandler(), ]; diff --git a/public/app/features/alerting/unified/mocks/silences.ts b/public/app/features/alerting/unified/mocks/silences.ts index e23e6ea9dedb6..75bf988ed2833 100644 --- a/public/app/features/alerting/unified/mocks/silences.ts +++ b/public/app/features/alerting/unified/mocks/silences.ts @@ -9,6 +9,17 @@ import { mockSilences } from 'app/features/alerting/unified/mocks'; export const silencesListHandler = (silences = mockSilences) => http.get('/api/alertmanager/:datasourceUid/api/v2/silences', () => HttpResponse.json(silences)); +export const silenceGetHandler = () => + http.get<{ uuid: string }>('/api/alertmanager/:datasourceUid/api/v2/silence/:uuid', ({ params }) => { + const { uuid } = params; + const matchingMockSilence = mockSilences.find((silence) => silence.id === uuid); + if (matchingMockSilence) { + return HttpResponse.json(matchingMockSilence); + } + + return HttpResponse.json({ message: 'silence not found' }, { status: 404 }); + }); + export const silenceCreateHandler = () => http.post('/api/alertmanager/:datasourceUid/api/v2/silences', () => HttpResponse.json({ silenceId: '4bda5b38-7939-4887-9ec2-16323b8e3b4e' }) From aa2f52a2a194e4050e83a8d52aa1e73a2bd3fe32 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 26 Apr 2024 16:15:45 +0100 Subject: [PATCH 26/53] Tidy up error message handling --- .../alerting/unified/components/silences/SilencesTable.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 6f9892244ec76..bf03af22087df 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -2,7 +2,6 @@ import { css } from '@emotion/css'; import React, { useMemo } from 'react'; import { dateMath, GrafanaTheme2 } from '@grafana/data'; -import { isFetchError } from '@grafana/runtime'; import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack, Alert, LoadingPlaceholder } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi'; @@ -109,8 +108,8 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => { ); } - if (isFetchError(error)) { - const errMessage = error?.message || 'Unknown error.'; + if (error) { + const errMessage = stringifyErrorLike(error) || 'Unknown error.'; return ( {errMessage} From e0ee0b09dbf8234aebb38e234d1d8ff8f1a0e682 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 29 Apr 2024 13:10:43 +0100 Subject: [PATCH 27/53] Add test to check that a broken alertmanager will handle errors correctly --- .../alerting/unified/Silences.test.tsx | 21 +++++++++++++++++-- .../alerting/unified/mocks/alertmanagerApi.ts | 12 +++++++---- .../alerting/unified/mocks/datasources.ts | 5 +++++ .../alerting/unified/mocks/silences.ts | 8 ++++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 84c35a27d93e4..6552cdcd5902c 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -1,11 +1,15 @@ import React from 'react'; -import { render, waitFor, userEvent } from 'test/test-utils'; +import { render, waitFor, userEvent, screen } from 'test/test-utils'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; import { dateTime } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, setDataSourceSrv } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { + MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER, + MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER, +} from 'app/features/alerting/unified/mocks/datasources'; import { waitForServerRequest } from 'app/features/alerting/unified/mocks/server/events'; import { silenceCreateHandler } from 'app/features/alerting/unified/mocks/silences'; import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; @@ -40,6 +44,11 @@ const dataSources = { name: 'Alertmanager', type: DataSourceType.Alertmanager, }), + [MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER]: mockDataSource({ + uid: MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER, + name: MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER, + type: DataSourceType.Alertmanager, + }), }; const ui = { @@ -100,6 +109,10 @@ const addAdditionalMatcher = async () => { setupMswServer(); +beforeEach(() => { + setupDataSources(dataSources.am, dataSources[MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER]); +}); + describe('Silences', () => { beforeAll(resetMocks); afterEach(resetMocks); @@ -185,6 +198,11 @@ describe('Silences', () => { expect(ui.addSilenceButton.query()).not.toBeInTheDocument(); }); + + it('handles error case when broken alertmanager is used', async () => { + renderSilences(`/alerting/silences?alertmanager=${encodeURIComponent(MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER)}`); + expect(await screen.findByText(/error loading silences/i)).toBeInTheDocument(); + }); }); describe('Silence create/edit', () => { @@ -194,7 +212,6 @@ describe('Silence create/edit', () => { beforeEach(() => { setUserLogged(true); - setupDataSources(dataSources.am); }); it('Should not render createdBy if user is logged in and has a name', async () => { diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts index 9c30d8ef69902..9083fd30a898f 100644 --- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw'; import { SetupServer } from 'msw/node'; import { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks'; +import { MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER } from 'app/features/alerting/unified/mocks/datasources'; import { AlertmanagerChoice, @@ -47,8 +48,11 @@ export function mockAlertmanagerConfigResponse( } export const alertmanagerAlertsListHandler = () => - http.get('/api/alertmanager/:datasourceUid/api/v2/alerts', () => - HttpResponse.json([ + http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/alerts', ({ params }) => { + if (params.datasourceUid === MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER) { + return HttpResponse.json({ traceId: '' }, { status: 502 }); + } + return HttpResponse.json([ mockAlertmanagerAlert({ labels: { foo: 'bar', buzz: 'bazz' }, status: { state: AlertState.Suppressed, silencedBy: [MOCK_SILENCE_ID_EXISTING], inhibitedBy: [] }, @@ -57,5 +61,5 @@ export const alertmanagerAlertsListHandler = () => labels: { foo: 'bar', buzz: 'bazz' }, status: { state: AlertState.Suppressed, silencedBy: [MOCK_SILENCE_ID_EXISTING], inhibitedBy: [] }, }), - ]) - ); + ]); + }); diff --git a/public/app/features/alerting/unified/mocks/datasources.ts b/public/app/features/alerting/unified/mocks/datasources.ts index 0ff5d12bf65de..04f579a7c241a 100644 --- a/public/app/features/alerting/unified/mocks/datasources.ts +++ b/public/app/features/alerting/unified/mocks/datasources.ts @@ -3,3 +3,8 @@ import { HttpResponse, http } from 'msw'; // TODO: Add more accurate endpoint responses as tests require export const datasourceBuildInfoHandler = () => http.get('/api/datasources/proxy/uid/:datasourceUid/api/v1/status/buildinfo', () => HttpResponse.json({})); + +/** UID of the alertmanager that is expected to be broken in tests */ +export const MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER = 'FwkfQfEmYlAthB'; +/** Display name of the alertmanager that is expected to be broken in tests */ +export const MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER = 'broken alertmanager'; diff --git a/public/app/features/alerting/unified/mocks/silences.ts b/public/app/features/alerting/unified/mocks/silences.ts index 75bf988ed2833..8ef9944d83f53 100644 --- a/public/app/features/alerting/unified/mocks/silences.ts +++ b/public/app/features/alerting/unified/mocks/silences.ts @@ -1,13 +1,19 @@ import { HttpResponse, http } from 'msw'; import { mockSilences } from 'app/features/alerting/unified/mocks'; +import { MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER } from 'app/features/alerting/unified/mocks/datasources'; ////////////// // Silences // ////////////// export const silencesListHandler = (silences = mockSilences) => - http.get('/api/alertmanager/:datasourceUid/api/v2/silences', () => HttpResponse.json(silences)); + http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/silences', ({ params }) => { + if (params.datasourceUid === MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER) { + return HttpResponse.json({ traceId: '' }, { status: 502 }); + } + return HttpResponse.json(silences); + }); export const silenceGetHandler = () => http.get<{ uuid: string }>('/api/alertmanager/:datasourceUid/api/v2/silence/:uuid', ({ params }) => { From a34d22dcbfaa3b08a7bb161dc344e4af1bcac60c Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 29 Apr 2024 13:48:34 +0100 Subject: [PATCH 28/53] Include alertmanager param on creation --- public/app/features/alerting/unified/Silences.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 6552cdcd5902c..e80fca33996ad 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -258,7 +258,7 @@ describe('Silence create/edit', () => { 'creates a new silence', async () => { const user = userEvent.setup(); - renderSilences(baseUrlPath); + renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`); expect(await ui.editor.durationField.find()).toBeInTheDocument(); const postRequest = waitForServerRequest(silenceCreateHandler()); From 0a02508415a4453ce7516b0a0aa5a8c9c5ca122a Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 29 Apr 2024 13:48:54 +0100 Subject: [PATCH 29/53] Add more robust error handling for stringifying API responses --- public/app/features/alerting/unified/utils/misc.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 32fc700f0aeda..8032c2f44875a 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -232,7 +232,17 @@ export function isErrorLike(error: unknown): error is Error { export function stringifyErrorLike(error: unknown): string { const fetchError = isFetchError(error); if (fetchError) { - return error.data.message; + if (error.message) { + return error.message; + } + if ('message' in error.data && typeof error.data.message === 'string') { + return error.data.message; + } + if (error.statusText) { + return error.statusText; + } + + return String(error.status) || 'Unknown error'; } return isErrorLike(error) ? error.message : String(error); From 4e6ba433ded19d1a526771ae718ef4716cad8a23 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Mon, 29 Apr 2024 16:55:55 +0200 Subject: [PATCH 30/53] Grafana/ui: Fix traces icon (#86984) --- public/img/icons/custom/gf-traces.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/img/icons/custom/gf-traces.svg b/public/img/icons/custom/gf-traces.svg index a58acc03951b9..ea83d6e50fbc4 100644 --- a/public/img/icons/custom/gf-traces.svg +++ b/public/img/icons/custom/gf-traces.svg @@ -1,5 +1,5 @@ - + From 7590f4afe102589fa6d21b55a1f2b9cdc49a3e77 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 29 Apr 2024 08:23:26 -0700 Subject: [PATCH 31/53] Canvas: Connection original persistence check (#86476) * Canvas: Connection original persistence check * modify current connection state directly instead of copying and needing to call "onChange" --------- Co-authored-by: nmarrs --- .../canvas/components/connections/ConnectionSVG.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index b192cb1ca0cbd..bd7bdf62a1bf7 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -128,7 +128,7 @@ export const ConnectionSVG = ({ // Render selected connection last, ensuring it is above other connections .sort((_a, b) => (selectedConnection === b && scene.panel.context.instanceState.selectedConnection ? -1 : 0)) .map((v, idx) => { - const { source, target, info, vertices } = v; + const { source, target, info, vertices, index } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; const transformScale = scene.scale; @@ -146,6 +146,15 @@ export const ConnectionSVG = ({ yStart = v.sourceOriginal.y; xEnd = v.targetOriginal.x; yEnd = v.targetOriginal.y; + } else if (source.options.connections) { + // If original source or target coordinates are not set for the current connection, set them + if ( + !source.options.connections[index].sourceOriginal || + !source.options.connections[index].targetOriginal + ) { + source.options.connections[index].sourceOriginal = { x: x1, y: y1 }; + source.options.connections[index].targetOriginal = { x: x2, y: y2 }; + } } const midpoint = calculateMidpoint(x1, y1, x2, y2); From 1af2e69625e62cab30d42555e32c9c907cc18cf0 Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 29 Apr 2024 17:23:41 +0200 Subject: [PATCH 32/53] Alerting: Implement DeleteSilence in the forked AM (remote primary) (#85721) --- .../remote/forked_alertmanager_test.go | 20 +++++++++++++------ .../remote_primary_forked_alertmanager.go | 8 +++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pkg/services/ngalert/remote/forked_alertmanager_test.go b/pkg/services/ngalert/remote/forked_alertmanager_test.go index 80d40da36171f..2379e02719dd3 100644 --- a/pkg/services/ngalert/remote/forked_alertmanager_test.go +++ b/pkg/services/ngalert/remote/forked_alertmanager_test.go @@ -451,15 +451,23 @@ func TestForkedAlertmanager_ModeRemotePrimary(t *testing.T) { }) t.Run("DeleteSilence", func(tt *testing.T) { - // We should delete the silence in the remote Alertmanager. - _, remote, forked := genTestAlertmanagers(tt, modeRemotePrimary) - remote.EXPECT().DeleteSilence(mock.Anything, mock.Anything).Return(nil).Once() - require.NoError(tt, forked.DeleteSilence(ctx, "")) + // We should delete the silence in both Alertmanagers. + testID := "test-id" + internal, remote, forked := genTestAlertmanagers(tt, modeRemotePrimary) + remote.EXPECT().DeleteSilence(mock.Anything, testID).Return(nil).Once() + internal.EXPECT().DeleteSilence(mock.Anything, testID).Return(nil).Once() + require.NoError(tt, forked.DeleteSilence(ctx, testID)) // If there's an error in the remote Alertmanager, the error should be returned. _, remote, forked = genTestAlertmanagers(tt, modeRemotePrimary) - remote.EXPECT().DeleteSilence(mock.Anything, mock.Anything).Return(expErr).Maybe() - require.ErrorIs(tt, expErr, forked.DeleteSilence(ctx, "")) + remote.EXPECT().DeleteSilence(mock.Anything, testID).Return(expErr).Maybe() + require.ErrorIs(tt, expErr, forked.DeleteSilence(ctx, testID)) + + // An error in the internal Alertmanager should not be returned. + internal, remote, forked = genTestAlertmanagers(tt, modeRemotePrimary) + remote.EXPECT().DeleteSilence(mock.Anything, testID).Return(nil).Maybe() + internal.EXPECT().DeleteSilence(mock.Anything, testID).Return(nil).Maybe() + require.NoError(tt, forked.DeleteSilence(ctx, testID)) }) t.Run("GetSilence", func(tt *testing.T) { diff --git a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go index 8c2068e817980..1084fff32d5f1 100644 --- a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go +++ b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go @@ -76,7 +76,13 @@ func (fam *RemotePrimaryForkedAlertmanager) CreateSilence(ctx context.Context, s } func (fam *RemotePrimaryForkedAlertmanager) DeleteSilence(ctx context.Context, id string) error { - return fam.remote.DeleteSilence(ctx, id) + if err := fam.remote.DeleteSilence(ctx, id); err != nil { + return err + } + if err := fam.internal.DeleteSilence(ctx, id); err != nil { + fam.log.Error("Error deleting silence in the internal Alertmanager", "err", err, "id", id) + } + return nil } func (fam *RemotePrimaryForkedAlertmanager) GetSilence(ctx context.Context, id string) (apimodels.GettableSilence, error) { From 8d8f19b84f7b5f70b0e24810f573ee8af19aaef1 Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Mon, 29 Apr 2024 11:27:59 -0400 Subject: [PATCH 33/53] Regenerate OpenAPI spec (#87050) Issue: https://github.com/grafana/grafana/issues/86453 The endpoints were documented in enterprise Grafana --- public/api-enterprise-spec.json | 286 +++++++++++++++++++++++++++++- public/api-merged.json | 266 ++++++++++++++++++++++++++++ public/openapi3.json | 299 ++++++++++++++++++++++++++++++++ 3 files changed, 847 insertions(+), 4 deletions(-) diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index e506c1d1ca647..c539729cf4cd7 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -760,6 +760,155 @@ } } }, + "/datasources/{dataSourceUID}/cache": { + "get": { + "description": "get cache config for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "getDataSourceCacheConfig", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "set cache config for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "setDataSourceCacheConfig", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CacheConfigSetter" + } + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/clean": { + "post": { + "description": "clean cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "cleanDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/disable": { + "post": { + "description": "disable cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "disableDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/enable": { + "post": { + "description": "enable cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "enableDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/licensing/check": { "get": { "tags": [ @@ -2502,6 +2651,123 @@ "Value": {} } }, + "CacheConfig": { + "description": "Config defines the internal representation of a cache configuration, including fields not set by the API caller", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "type": "integer", + "format": "int64" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, + "CacheConfigResponse": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "type": "integer", + "format": "int64" + }, + "enabled": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, + "CacheConfigSetter": { + "description": "ConfigSetter defines the cache parameters that users can configure per datasource\nThis is only intended to be consumed by the SetCache HTTP Handler", + "type": "object", + "properties": { + "dataSourceID": { + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, "CalculateDiffTarget": { "type": "object", "properties": { @@ -2666,7 +2932,15 @@ "type": "string" } }, + "Policies": { + "description": "Policies contains all policy identifiers included in the certificate.\nIn Go 1.22, encoding/gob cannot handle and ignores this field.", + "type": "array", + "items": { + "$ref": "#/definitions/OID" + } + }, "PolicyIdentifiers": { + "description": "PolicyIdentifiers contains asn1.ObjectIdentifiers, the components\nof which are limited to int32. If a certificate contains a policy which\ncannot be represented by asn1.ObjectIdentifier, it will not be included in\nPolicyIdentifiers, but will be present in Policies, which contains all parsed\npolicy OIDs.", "type": "array", "items": { "$ref": "#/definitions/ObjectIdentifier" @@ -4396,7 +4670,7 @@ "type": "string" }, "IPMask": { - "description": "See type IPNet and func ParseCIDR for details.", + "description": "See type [IPNet] and func [ParseCIDR] for details.", "type": "array", "title": "An IPMask is a bitmask that can be used to manipulate\nIP addresses for IP addressing and routing.", "items": { @@ -4945,7 +5219,7 @@ } }, "Name": { - "description": "Name represents an X.509 distinguished name. This only includes the common\nelements of a DN. Note that Name is only an approximation of the X.509\nstructure. If an accurate representation is needed, asn1.Unmarshal the raw\nsubject or issuer as an RDNSequence.", + "description": "Name represents an X.509 distinguished name. This only includes the common\nelements of a DN. Note that Name is only an approximation of the X.509\nstructure. If an accurate representation is needed, asn1.Unmarshal the raw\nsubject or issuer as an [RDNSequence].", "type": "object", "properties": { "Country": { @@ -5028,6 +5302,10 @@ "format": "int64", "title": "NoticeSeverity is a type for the Severity property of a Notice." }, + "OID": { + "type": "object", + "title": "An OID represents an ASN.1 OBJECT IDENTIFIER." + }, "ObjectIdentifier": { "type": "array", "title": "An ObjectIdentifier represents an ASN.1 OBJECT IDENTIFIER.", @@ -7081,7 +7359,7 @@ } }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { @@ -7693,7 +7971,7 @@ } }, "Userinfo": { - "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", + "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", "type": "object" }, "ValueMapping": { diff --git a/public/api-merged.json b/public/api-merged.json index 2ddc5d1048526..fe9e69f24ccbf 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -4303,6 +4303,155 @@ } } }, + "/datasources/{dataSourceUID}/cache": { + "get": { + "description": "get cache config for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "getDataSourceCacheConfig", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + }, + "post": { + "description": "set cache config for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "setDataSourceCacheConfig", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CacheConfigSetter" + } + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/clean": { + "post": { + "description": "clean cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "cleanDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/disable": { + "post": { + "description": "disable cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "disableDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, + "/datasources/{dataSourceUID}/cache/enable": { + "post": { + "description": "enable cache for a single data source", + "tags": [ + "enterprise" + ], + "operationId": "enableDataSourceCache", + "parameters": [ + { + "type": "string", + "name": "dataSourceUID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CacheConfigResponse", + "schema": { + "$ref": "#/definitions/CacheConfigResponse" + } + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/datasources/{id}": { "get": { "description": "If you are running Grafana Enterprise and have Fine-grained access control enabled\nyou need to have a permission with action: `datasources:read` and scopes: `datasources:*`, `datasources:id:*` and `datasources:id:1` (single data source).\n\nPlease refer to [updated API](#/datasources/getDataSourceByUID) instead", @@ -12480,6 +12629,123 @@ } } }, + "CacheConfig": { + "description": "Config defines the internal representation of a cache configuration, including fields not set by the API caller", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "type": "integer", + "format": "int64" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, + "CacheConfigResponse": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "type": "integer", + "format": "int64" + }, + "enabled": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "updated": { + "type": "string", + "format": "date-time" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, + "CacheConfigSetter": { + "description": "ConfigSetter defines the cache parameters that users can configure per datasource\nThis is only intended to be consumed by the SetCache HTTP Handler", + "type": "object", + "properties": { + "dataSourceID": { + "type": "integer", + "format": "int64" + }, + "dataSourceUID": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "type": "integer", + "format": "int64" + }, + "ttlResourcesMs": { + "type": "integer", + "format": "int64" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + } + }, "CalculateDiffTarget": { "type": "object", "properties": { diff --git a/public/openapi3.json b/public/openapi3.json index 5306e9f14bc46..420d9bfa204a6 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -3253,6 +3253,123 @@ "title": "BasicAuth contains basic HTTP authentication credentials.", "type": "object" }, + "CacheConfig": { + "description": "Config defines the internal representation of a cache configuration, including fields not set by the API caller", + "properties": { + "created": { + "format": "date-time", + "type": "string" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "format": "int64", + "type": "integer" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "format": "int64", + "type": "integer" + }, + "ttlResourcesMs": { + "format": "int64", + "type": "integer" + }, + "updated": { + "format": "date-time", + "type": "string" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + }, + "type": "object" + }, + "CacheConfigResponse": { + "properties": { + "created": { + "format": "date-time", + "type": "string" + }, + "dataSourceID": { + "description": "Fields that can be set by the API caller - read/write", + "format": "int64", + "type": "integer" + }, + "dataSourceUID": { + "type": "string" + }, + "defaultTTLMs": { + "description": "These are returned by the HTTP API, but are managed internally - read-only\nNote: 'created' and 'updated' are special properties managed automatically by xorm, but we are setting them manually", + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "format": "int64", + "type": "integer" + }, + "ttlResourcesMs": { + "format": "int64", + "type": "integer" + }, + "updated": { + "format": "date-time", + "type": "string" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + }, + "type": "object" + }, + "CacheConfigSetter": { + "description": "ConfigSetter defines the cache parameters that users can configure per datasource\nThis is only intended to be consumed by the SetCache HTTP Handler", + "properties": { + "dataSourceID": { + "format": "int64", + "type": "integer" + }, + "dataSourceUID": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ttlQueriesMs": { + "description": "TTL MS, or \"time to live\", is how long a cached item will stay in the cache before it is removed (in milliseconds)", + "format": "int64", + "type": "integer" + }, + "ttlResourcesMs": { + "format": "int64", + "type": "integer" + }, + "useDefaultTTL": { + "description": "If UseDefaultTTL is enabled, then the TTLQueriesMS and TTLResourcesMS in this object is always sent as the default TTL located in grafana.ini", + "type": "boolean" + } + }, + "type": "object" + }, "CalculateDiffTarget": { "properties": { "dashboardId": { @@ -16976,6 +17093,188 @@ ] } }, + "/datasources/{dataSourceUID}/cache": { + "get": { + "description": "get cache config for a single data source", + "operationId": "getDataSourceCacheConfig", + "parameters": [ + { + "in": "path", + "name": "dataSourceUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigResponse" + } + } + }, + "description": "CacheConfigResponse" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "tags": [ + "enterprise" + ] + }, + "post": { + "description": "set cache config for a single data source", + "operationId": "setDataSourceCacheConfig", + "parameters": [ + { + "in": "path", + "name": "dataSourceUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigSetter" + } + } + }, + "required": true, + "x-originalParamName": "body" + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigResponse" + } + } + }, + "description": "CacheConfigResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "tags": [ + "enterprise" + ] + } + }, + "/datasources/{dataSourceUID}/cache/clean": { + "post": { + "description": "clean cache for a single data source", + "operationId": "cleanDataSourceCache", + "parameters": [ + { + "in": "path", + "name": "dataSourceUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigResponse" + } + } + }, + "description": "CacheConfigResponse" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "tags": [ + "enterprise" + ] + } + }, + "/datasources/{dataSourceUID}/cache/disable": { + "post": { + "description": "disable cache for a single data source", + "operationId": "disableDataSourceCache", + "parameters": [ + { + "in": "path", + "name": "dataSourceUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigResponse" + } + } + }, + "description": "CacheConfigResponse" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "tags": [ + "enterprise" + ] + } + }, + "/datasources/{dataSourceUID}/cache/enable": { + "post": { + "description": "enable cache for a single data source", + "operationId": "enableDataSourceCache", + "parameters": [ + { + "in": "path", + "name": "dataSourceUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheConfigResponse" + } + } + }, + "description": "CacheConfigResponse" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "tags": [ + "enterprise" + ] + } + }, "/datasources/{id}": { "delete": { "deprecated": true, From 49fbe970fb9dceca72a7455f5c2a77cf85c4f2a8 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 29 Apr 2024 09:16:01 -0700 Subject: [PATCH 34/53] Canvas: Fix connection hyperbolic bug (#87002) * Canvas: Connection original persistence check * Canvas: Fix connection hyperbolic bug --- .../panel/canvas/components/connections/ConnectionSVG.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index bd7bdf62a1bf7..40ea21ee2b4d8 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -259,8 +259,8 @@ export const ConnectionSVG = ({ if (index < vertices.length - 1) { // Not also the last point const nextVertex = vertices[index + 1]; - Xn = nextVertex.x * xDist + x1; - Yn = nextVertex.y * yDist + y1; + Xn = nextVertex.x * xDist + xStart; + Yn = nextVertex.y * yDist + yStart; } // Length of next segment From 36a049912872523eac161a8a8965a03640b31ae2 Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 29 Apr 2024 18:47:25 +0200 Subject: [PATCH 35/53] Alerting: Implement CreateSilence in the forked Alertmanager (remote primary mode) (#85716) --- .../remote/forked_alertmanager_test.go | 26 ++++++++++++++----- .../remote_primary_forked_alertmanager.go | 11 +++++++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/pkg/services/ngalert/remote/forked_alertmanager_test.go b/pkg/services/ngalert/remote/forked_alertmanager_test.go index 2379e02719dd3..52a04116fbbcf 100644 --- a/pkg/services/ngalert/remote/forked_alertmanager_test.go +++ b/pkg/services/ngalert/remote/forked_alertmanager_test.go @@ -435,19 +435,31 @@ func TestForkedAlertmanager_ModeRemotePrimary(t *testing.T) { }) t.Run("CreateSilence", func(tt *testing.T) { - // We should create the silence in the remote Alertmanager. - _, remote, forked := genTestAlertmanagers(tt, modeRemotePrimary) - + // We should create the silence in both Alertmanagers using the same uid. + testSilence := &apimodels.PostableSilence{} expID := "test-id" - remote.EXPECT().CreateSilence(mock.Anything, mock.Anything).Return(expID, nil).Once() - id, err := forked.CreateSilence(ctx, nil) + + internal, remote, forked := genTestAlertmanagers(tt, modeRemotePrimary) + remote.EXPECT().CreateSilence(mock.Anything, testSilence).Return(expID, nil).Once() + internal.EXPECT().CreateSilence(mock.Anything, testSilence).Return(testSilence.ID, nil).Once() + id, err := forked.CreateSilence(ctx, testSilence) require.NoError(tt, err) + require.Equal(tt, expID, testSilence.ID) require.Equal(tt, expID, id) // If there's an error in the remote Alertmanager, the error should be returned. - remote.EXPECT().CreateSilence(mock.Anything, mock.Anything).Return("", expErr).Maybe() - _, err = forked.CreateSilence(ctx, nil) + _, remote, forked = genTestAlertmanagers(tt, modeRemotePrimary) + remote.EXPECT().CreateSilence(mock.Anything, mock.Anything).Return("", expErr).Once() + _, err = forked.CreateSilence(ctx, testSilence) require.ErrorIs(tt, expErr, err) + + // An error in the internal Alertmanager should not be returned. + internal, remote, forked = genTestAlertmanagers(tt, modeRemotePrimary) + remote.EXPECT().CreateSilence(mock.Anything, mock.Anything).Return(expID, nil).Once() + internal.EXPECT().CreateSilence(mock.Anything, mock.Anything).Return("", expErr).Once() + id, err = forked.CreateSilence(ctx, testSilence) + require.NoError(tt, err) + require.Equal(tt, expID, id) }) t.Run("DeleteSilence", func(tt *testing.T) { diff --git a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go index 1084fff32d5f1..127d9d82e6f65 100644 --- a/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go +++ b/pkg/services/ngalert/remote/remote_primary_forked_alertmanager.go @@ -72,7 +72,16 @@ func (fam *RemotePrimaryForkedAlertmanager) GetStatus() apimodels.GettableStatus } func (fam *RemotePrimaryForkedAlertmanager) CreateSilence(ctx context.Context, silence *apimodels.PostableSilence) (string, error) { - return fam.remote.CreateSilence(ctx, silence) + uid, err := fam.remote.CreateSilence(ctx, silence) + if err != nil { + return "", err + } + + silence.ID = uid + if _, err := fam.internal.CreateSilence(ctx, silence); err != nil { + fam.log.Error("Error creating silence in the internal Alertmanager", "err", err, "silence", silence) + } + return uid, nil } func (fam *RemotePrimaryForkedAlertmanager) DeleteSilence(ctx context.Context, id string) error { From b679a32fad63eb1bda5b54c946b1de8921b55c68 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Mon, 29 Apr 2024 18:48:54 +0200 Subject: [PATCH 36/53] Alerting: Allow deleting contact points referenced only by auto-generated policies (#86800) --- .../contact-points/ContactPoints.test.tsx | 40 ++++++++++++++++--- .../contact-points/ContactPoints.tsx | 33 +++++++++------ .../useContactPoints.test.tsx.snap | 34 +++++++++++----- .../contact-points/components/Modals.tsx | 6 ++- .../components/contact-points/utils.ts | 30 ++++++++++---- .../SimplifiedRuleEditor.test.tsx | 3 +- 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index 626399aa627b2..b7fc3ee7c3763 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -21,6 +21,7 @@ import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimi import setupVanillaAlertmanagerFlavoredServer, { VANILLA_ALERTMANAGER_DATASOURCE_UID, } from './__mocks__/vanillaAlertmanagerServer'; +import { RouteReference } from './utils'; /** * There are lots of ways in which we test our pages and components. Here's my opinionated approach to testing them. @@ -224,13 +225,19 @@ describe('contact points', () => { expect(deleteButton).toBeDisabled(); }); - it('should disable delete when contact point is linked to at least one notification policy', async () => { - render( - , + it('should disable delete when contact point is linked to at least one normal notification policy', async () => { + const policies: RouteReference[] = [ { - wrapper, - } - ); + receiver: 'my-contact-point', + route: { + type: 'normal', + }, + }, + ]; + + render(, { + wrapper, + }); expect(screen.getByRole('link', { name: 'is used by 1 notification policy' })).toBeInTheDocument(); @@ -241,6 +248,27 @@ describe('contact points', () => { expect(deleteButton).toBeDisabled(); }); + it('should not disable delete when contact point is linked only to auto-generated notification policy', async () => { + const policies: RouteReference[] = [ + { + receiver: 'my-contact-point', + route: { + type: 'auto-generated', + }, + }, + ]; + + render(, { + wrapper, + }); + + const moreActions = screen.getByRole('button', { name: 'more-actions' }); + await userEvent.click(moreActions); + + const deleteButton = screen.getByRole('menuitem', { name: /delete/i }); + expect(deleteButton).not.toBeDisabled(); + }); + it('should be able to search', async () => { renderWithProvider(); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 9bd140ec3053c..f8733d208b9e5 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -60,7 +60,13 @@ import { useContactPointsWithStatus, useDeleteContactPoint, } from './useContactPoints'; -import { ContactPointWithMetadata, getReceiverDescription, isProvisioned, ReceiverConfigWithMetadata } from './utils'; +import { + ContactPointWithMetadata, + getReceiverDescription, + isProvisioned, + ReceiverConfigWithMetadata, + RouteReference, +} from './utils'; export enum ActiveTab { ContactPoints = 'contact_points', @@ -243,7 +249,7 @@ const ContactPointsList = ({ <> {pageItems.map((contactPoint, index) => { const provisioned = isProvisioned(contactPoint); - const policies = contactPoint.numberOfPolicies; + const policies = contactPoint.policies ?? []; const key = `${contactPoint.name}-${index}`; return ( @@ -304,7 +310,7 @@ interface ContactPointProps { disabled?: boolean; provisioned?: boolean; receivers: ReceiverConfigWithMetadata[]; - policies?: number; + policies?: RouteReference[]; onDelete: (name: string) => void; } @@ -313,7 +319,7 @@ export const ContactPoint = ({ disabled = false, provisioned = false, receivers, - policies = 0, + policies = [], onDelete, }: ContactPointProps) => { const styles = useStyles2(getStyles); @@ -367,12 +373,12 @@ interface ContactPointHeaderProps { name: string; disabled?: boolean; provisioned?: boolean; - policies?: number; + policies?: RouteReference[]; onDelete: (name: string) => void; } const ContactPointHeader = (props: ContactPointHeaderProps) => { - const { name, disabled = false, provisioned = false, policies = 0, onDelete } = props; + const { name, disabled = false, provisioned = false, policies = [], onDelete } = props; const styles = useStyles2(getStyles); const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint); @@ -381,9 +387,12 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { const [ExportDrawer, openExportDrawer] = useExportContactPoint(); - const isReferencedByPolicies = policies > 0; + const numberOfPolicies = policies.length; + const isReferencedByAnyPolicy = numberOfPolicies > 0; + const isReferencedByRegularPolicies = policies.some((ref) => ref.route.type !== 'auto-generated'); + const canEdit = editSupported && editAllowed && !provisioned; - const canDelete = deleteSupported && deleteAllowed && !provisioned && policies === 0; + const canDelete = deleteSupported && deleteAllowed && !provisioned && !isReferencedByRegularPolicies; const menuActions: JSX.Element[] = []; @@ -407,7 +416,7 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { menuActions.push( ( {children} @@ -434,15 +443,15 @@ const ContactPointHeader = (props: ContactPointHeaderProps) => { {name} - {isReferencedByPolicies && ( + {isReferencedByAnyPolicy && ( - is used by {policies} {pluralize('notification policy', policies)} + is used by {numberOfPolicies} {pluralize('notification policy', numberOfPolicies)} )} {provisioned && } - {!isReferencedByPolicies && } + {!isReferencedByAnyPolicy && } = [JSX.Element, (item: T) => void, () => void]; /** @@ -83,7 +85,9 @@ const ErrorModal = ({ isOpen, onDismiss, error }: ErrorModalProps) => ( >

Failed to update your configuration:

- {String(error)} +

+        {stringifyErrorLike(error)}
+      

); diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts index 6af5766d47359..e472fb07f14bf 100644 --- a/public/app/features/alerting/unified/components/contact-points/utils.ts +++ b/public/app/features/alerting/unified/components/contact-points/utils.ts @@ -1,4 +1,4 @@ -import { countBy, difference, take, trim, upperFirst } from 'lodash'; +import { difference, groupBy, take, trim, upperFirst } from 'lodash'; import { ReactNode } from 'react'; import { config } from '@grafana/runtime'; @@ -99,7 +99,7 @@ export interface ReceiverConfigWithMetadata extends GrafanaManagedReceiverConfig } export interface ContactPointWithMetadata extends GrafanaManagedContactPoint { - numberOfPolicies?: number; // now is optional as we don't have the data from the read-only endpoint + policies?: RouteReference[]; // now is optional as we don't have the data from the read-only endpoint grafana_managed_receiver_configs: ReceiverConfigWithMetadata[]; } @@ -121,7 +121,7 @@ export function enhanceContactPointsWithMetadata( // compute the entire inherited tree before finding what notification policies are using a particular contact point const fullyInheritedTree = computeInheritedTree(alertmanagerConfiguration?.alertmanager_config?.route ?? {}); const usedContactPoints = getUsedContactPoints(fullyInheritedTree); - const usedContactPointsByName = countBy(usedContactPoints); + const usedContactPointsByName = groupBy(usedContactPoints, 'receiver'); const contactPointsList = alertmanagerConfiguration ? alertmanagerConfiguration?.alertmanager_config.receivers ?? [] @@ -133,8 +133,8 @@ export function enhanceContactPointsWithMetadata( return { ...contactPoint, - numberOfPolicies: - alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? 0), + policies: + alertmanagerConfiguration && usedContactPointsByName && (usedContactPointsByName[contactPoint.name] ?? []), grafana_managed_receiver_configs: receivers.map((receiver, index) => { const isOnCallReceiver = receiver.type === ReceiverTypes.OnCall; // if we don't have alertmanagerConfiguration we can't get the metadata for oncall receivers, @@ -171,10 +171,26 @@ export function isAutoGeneratedPolicy(route: Route) { ); } -export function getUsedContactPoints(route: Route): string[] { +export interface RouteReference { + receiver: string; + route: { + type: 'auto-generated' | 'normal'; + }; +} + +export function getUsedContactPoints(route: Route): RouteReference[] { const childrenContactPoints = route.routes?.flatMap((route) => getUsedContactPoints(route)) ?? []; + if (route.receiver) { - return [route.receiver, ...childrenContactPoints]; + return [ + { + receiver: route.receiver, + route: { + type: isAutoGeneratedPolicy(route) ? 'auto-generated' : 'normal', + }, + }, + ...childrenContactPoints, + ]; } return childrenContactPoints; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index e449a25f8eae8..6535e12352235 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -263,6 +263,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', () expect(mocks.api.setRulerRuleGroup).not.toHaveBeenCalled(); }); }); + it('can create new grafana managed alert when using simplified routing and selecting a contact point', async () => { const contactPointsAvailable: ContactPointWithMetadata[] = [ { @@ -279,7 +280,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', () settings: {}, }, ], - numberOfPolicies: 0, + policies: [], }, ]; mocks.useContactPointsWithStatus.mockReturnValue({ From 7b392d40a019bfbc51d63431ca3e748ad7e4c43e Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Mon, 29 Apr 2024 13:07:45 -0400 Subject: [PATCH 37/53] Auth: Sign sigV4 request after adding headers (#87063) --- .../httpclientprovider/http_client_provider.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index 5447999a1fd29..13ef9674c1c31 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -32,10 +32,6 @@ func New(cfg *setting.Cfg, validator validations.PluginRequestValidator, tracer RedirectLimitMiddleware(validator), } - if cfg.SigV4AuthEnabled { - middlewares = append(middlewares, awssdk.SigV4Middleware(cfg.SigV4VerboseLogging)) - } - if httpLoggingEnabled(cfg.PluginSettings) { middlewares = append(middlewares, HTTPLoggerMiddleware(cfg.PluginSettings)) } @@ -44,6 +40,11 @@ func New(cfg *setting.Cfg, validator validations.PluginRequestValidator, tracer middlewares = append(middlewares, GrafanaRequestIDHeaderMiddleware(cfg, logger)) } + // SigV4 signing should be performed after all headers are added + if cfg.SigV4AuthEnabled { + middlewares = append(middlewares, awssdk.SigV4Middleware(cfg.SigV4VerboseLogging)) + } + setDefaultTimeoutOptions(cfg) return newProviderFunc(sdkhttpclient.ProviderOptions{ From 3845033308f1914a685ac3c7514352b834a80319 Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Mon, 29 Apr 2024 12:09:47 -0500 Subject: [PATCH 38/53] Docs: Update Explore Metrics doc based on feedback (#87062) * changed from private preview to public preview * commented out pivot to logs and traces --- docs/sources/explore/explore-metrics.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sources/explore/explore-metrics.md b/docs/sources/explore/explore-metrics.md index 7433d18c45574..6ad02a1d82409 100644 --- a/docs/sources/explore/explore-metrics.md +++ b/docs/sources/explore/explore-metrics.md @@ -14,7 +14,9 @@ weight: 200 Grafana Explore Metrics is a query-less experience for browsing **Prometheus-compatible** metrics. Quickly find related metrics with just a few simple clicks, without needing to write PromQL queries to retrieve metrics. -{{< docs/public-preview product="Explore Metrics" >}} +{{% admonition type="caution" %}} +Explore Metrics is currently in [public preview](/docs/release-life-cycle/). Grafana Labs offers limited support, and breaking changes might occur prior to the feature being made generally available. +{{% /admonition %}} With Explore Metrics, you can: @@ -23,7 +25,7 @@ With Explore Metrics, you can: - surface other metrics relevant to the current metric - “explore in a drawer” - expand a drawer over a dashboard with more content so you don’t lose your place - view a history of user steps when navigating through metrics and their filters -- easily pivot to other related telemetry, including logs or traces + You can access Explore Metrics either as a standalone experience or as part of Grafana dashboards. From 70ff229bed758374de1faecbb058cf9dcb9ee0d7 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 29 Apr 2024 13:13:29 -0400 Subject: [PATCH 39/53] Alerting: Use expected field name for receiver in HCL export (#87065) * Use expected field name for receiver in hcl Terraform provider expects `contact_point` instead of `receiver` in notification settings on a rule. --- pkg/services/ngalert/api/api_provisioning_test.go | 2 +- .../ngalert/api/test-data/post-rulegroup-101-export.hcl | 2 +- .../api/tooling/definitions/provisioning_alert_rules.go | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index e2db47386db92..0d62ee98ed37e 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -694,7 +694,7 @@ func TestProvisioningApi(t *testing.T) { is_paused = false notification_settings { - receiver = "Test-Receiver" + contact_point = "Test-Receiver" group_by = ["alertname", "grafana_folder", "test"] group_wait = "1s" group_interval = "5s" diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl index 2cfb1ba5b0ef5..19810a7155440 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl @@ -79,7 +79,7 @@ resource "grafana_rule_group" "rule_group_0000" { is_paused = false notification_settings { - receiver = "Test-Receiver" + contact_point = "Test-Receiver" group_by = ["alertname", "grafana_folder", "test"] group_wait = "1s" group_interval = "5s" diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index bd8e1548861e0..0ebd6384a73be 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -292,7 +292,8 @@ type RelativeTimeRangeExport struct { // AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings. type AlertRuleNotificationSettingsExport struct { - Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty" hcl:"receiver"` + // Terraform provider uses `contact_point`, so export the field with that name in HCL. + Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty" hcl:"contact_point"` GroupBy []string `yaml:"group_by,omitempty" json:"group_by,omitempty" hcl:"group_by"` GroupWait *string `yaml:"group_wait,omitempty" json:"group_wait,omitempty" hcl:"group_wait,optional"` From 16395f9f23519987ef71c499ea8bc7e24c3e79df Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 29 Apr 2024 20:41:40 +0200 Subject: [PATCH 40/53] Pyroscope: Add adhoc filters support (#85601) * Add adhoc filters support * Add tests * refactor tests * Add comment * Removed empty param docs --- packages/grafana-data/src/types/time.ts | 17 +++++ .../datasource.test.ts | 67 ++++++++++++------- .../datasource.ts | 65 ++++++++++-------- .../grafana-pyroscope-datasource/utils.ts | 57 ++++++++++++++-- 4 files changed, 147 insertions(+), 59 deletions(-) diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index edc92429ca6c9..f3eeefac9ce63 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -86,3 +86,20 @@ export function getDefaultRelativeTimeRange(): RelativeTimeRange { to: 0, }; } + +/** + * Simple helper to quickly create a TimeRange object either from string representations of a dateTime or directly + * DateTime objects. + */ +export function makeTimeRange(from: DateTime | string, to: DateTime | string): TimeRange { + const fromDateTime = typeof from === 'string' ? dateTime(from) : from; + const toDateTime = typeof to === 'string' ? dateTime(to) : to; + return { + from: fromDateTime, + to: toDateTime, + raw: { + from: fromDateTime, + to: toDateTime, + }, + }; +} diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index 6c4720c06005a..9d1c9246dba9a 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -5,27 +5,14 @@ import { PluginMetaInfo, PluginType, DataSourceJsonData, + makeTimeRange, } from '@grafana/data'; -import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, getTemplateSrv } from '@grafana/runtime'; +import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime'; import { defaultPyroscopeQueryType } from './dataquery.gen'; import { normalizeQuery, PyroscopeDataSource } from './datasource'; import { Query } from './types'; -jest.mock('@grafana/runtime', () => { - const actual = jest.requireActual('@grafana/runtime'); - return { - ...actual, - getTemplateSrv: () => { - return { - replace: (query: string): string => { - return query.replace(/\$var/g, 'interpolated'); - }, - }; - }, - }; -}); - /** The datasource QueryEditor fetches datasource settings to send to the extension's `configure` method */ export function mockFetchPyroscopeDatasourceSettings( datasourceSettings?: Partial> @@ -46,16 +33,21 @@ export function mockFetchPyroscopeDatasourceSettings( }); } -describe('Pyroscope data source', () => { - let ds: PyroscopeDataSource; - beforeEach(() => { - mockFetchPyroscopeDatasourceSettings(); - setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions - ds = new PyroscopeDataSource(defaultSettings); - }); +function setupDatasource() { + mockFetchPyroscopeDatasourceSettings(); + setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions + const templateSrv = { + replace: (query: string): string => { + return query.replace(/\$var/g, 'interpolated'); + }, + } as unknown as TemplateSrv; + return new PyroscopeDataSource(defaultSettings, templateSrv); +} +describe('Pyroscope data source', () => { describe('importing queries', () => { it('keeps all labels and values', async () => { + const ds = setupDatasource(); const queries = await ds.importFromAbstractQueries([ { refId: 'A', @@ -71,6 +63,7 @@ describe('Pyroscope data source', () => { describe('exporting queries', () => { it('keeps all labels and values', async () => { + const ds = setupDatasource(); const queries = await ds.exportToAbstractQueries([ { refId: 'A', @@ -93,10 +86,8 @@ describe('Pyroscope data source', () => { }); describe('applyTemplateVariables', () => { - const templateSrv = getTemplateSrv(); - it('should not update labelSelector if there are no template variables', () => { - ds = new PyroscopeDataSource(defaultSettings, templateSrv); + const ds = setupDatasource(); const query = ds.applyTemplateVariables(defaultQuery({ labelSelector: `no var`, profileTypeId: 'no var' }), {}); expect(query).toMatchObject({ labelSelector: `no var`, @@ -105,7 +96,7 @@ describe('Pyroscope data source', () => { }); it('should update labelSelector if there are template variables', () => { - ds = new PyroscopeDataSource(defaultSettings, templateSrv); + const ds = setupDatasource(); const query = ds.applyTemplateVariables( defaultQuery({ labelSelector: `{$var="$var"}`, profileTypeId: '$var' }), {} @@ -113,6 +104,30 @@ describe('Pyroscope data source', () => { expect(query).toMatchObject({ labelSelector: `{interpolated="interpolated"}`, profileTypeId: 'interpolated' }); }); }); + + it('implements ad hoc variable support for keys', async () => { + const ds = setupDatasource(); + jest.spyOn(ds, 'getResource').mockImplementationOnce(async (cb) => ['foo', 'bar', 'baz']); + const keys = await ds.getTagKeys({ + filters: [], + timeRange: makeTimeRange('2024-01-01T00:00:00', '2024-01-01T01:00:00'), + }); + expect(keys).toEqual(['foo', 'bar', 'baz'].map((v) => ({ text: v }))); + }); + + it('implements ad hoc variable support for values', async () => { + const ds = setupDatasource(); + jest.spyOn(ds, 'getResource').mockImplementationOnce(async (path, params) => { + expect(params?.label).toEqual('foo'); + return ['xyz', 'tuv']; + }); + const keys = await ds.getTagValues({ + key: 'foo', + filters: [], + timeRange: makeTimeRange('2024-01-01T00:00:00', '2024-01-01T01:00:00'), + }); + expect(keys).toEqual(['xyz', 'tuv'].map((v) => ({ text: v }))); + }); }); describe('normalizeQuery', () => { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts index 52d50b6995a84..a748d56026980 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.ts @@ -1,12 +1,16 @@ -import Prism, { Grammar } from 'prismjs'; +import Prism from 'prismjs'; import { Observable, of } from 'rxjs'; import { AbstractQuery, + AdHocVariableFilter, CoreApp, DataQueryRequest, DataQueryResponse, + DataSourceGetTagKeysOptions, + DataSourceGetTagValuesOptions, DataSourceInstanceSettings, + MetricFindValue, ScopedVars, } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; @@ -14,7 +18,7 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/run import { VariableSupport } from './VariableSupport'; import { defaultGrafanaPyroscopeDataQuery, defaultPyroscopeQueryType } from './dataquery.gen'; import { PyroscopeDataSourceOptions, Query, ProfileTypeMessage } from './types'; -import { extractLabelMatchers, toPromLikeExpr } from './utils'; +import { addLabelToQuery, extractLabelMatchers, grammar, toPromLikeExpr } from './utils'; export class PyroscopeDataSource extends DataSourceWithBackend { constructor( @@ -71,10 +75,37 @@ export class PyroscopeDataSource extends DataSourceWithBackend): Promise { + const data = this.adhocFilterData(options); + const labels = await this.getLabelNames(data.query, data.from, data.to); + return labels.map((label) => ({ text: label })); + } + + // By implementing getTagKeys and getTagValues we add ad-hoc filters functionality + async getTagValues(options: DataSourceGetTagValuesOptions): Promise { + const data = this.adhocFilterData(options); + const labels = await this.getLabelValues(data.query, options.key, data.from, data.to); + return labels.map((label) => ({ text: label })); + } + + private adhocFilterData(options: DataSourceGetTagKeysOptions | DataSourceGetTagValuesOptions) { + const from = options.timeRange?.from.valueOf() ?? Date.now() - 1000 * 60 * 60 * 24; + const to = options.timeRange?.to.valueOf() ?? Date.now(); + const query = '{' + options.filters.map((f) => `${f.key}${f.operator}"${f.value}"`).join(',') + '}'; + return { from, to, query }; + } + + applyTemplateVariables(query: Query, scopedVars: ScopedVars, filters?: AdHocVariableFilter[]): Query { + let labelSelector = this.templateSrv.replace(query.labelSelector ?? '', scopedVars); + if (filters && labelSelector) { + for (const filter of filters) { + labelSelector = addLabelToQuery(labelSelector, filter.key, filter.value, filter.operator); + } + } return { ...query, - labelSelector: this.templateSrv.replace(query.labelSelector ?? '', scopedVars), + labelSelector, profileTypeId: this.templateSrv.replace(query.profileTypeId ?? '', scopedVars), }; } @@ -86,7 +117,7 @@ export class PyroscopeDataSource extends DataSourceWithBackend): AbstractLabelMatcher[] { const labelMatchers: AbstractLabelMatcher[] = []; @@ -47,8 +47,8 @@ export function extractLabelMatchers(tokens: Array): AbstractLab return labelMatchers; } -export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string { - const expr = labelBasedQuery.labelMatchers +export function toPromLikeExpr(labelMatchers: AbstractLabelMatcher[]): string { + const expr = labelMatchers .map((selector: AbstractLabelMatcher) => { const operator = ToPromLikeMap[selector.operator]; if (operator) { @@ -82,3 +82,52 @@ const ToPromLikeMap: Record = invert(FromPromLike AbstractLabelOperator, string >; + +/** + * Modifies query, adding a new label=value pair to it while preserving other parts of the query. This operates on a + * string representation of the query which needs to be parsed and then rendered to string again. + */ +export function addLabelToQuery(query: string, key: string, value: string | number, operator = '='): string { + if (!key || !value) { + throw new Error('Need label to add to query.'); + } + + const tokens = Prism.tokenize(query, grammar); + let labels = extractLabelMatchers(tokens); + + // If we already have such label in the query, remove it and we will replace it. If we didn't we would end up + // with query like `a=b,a=c` which won't return anything. Replacing also seems more meaningful here than just + // ignoring the filter and keeping the old value. + labels = labels.filter((l) => l.name !== key); + labels.push({ + name: key, + value: value.toString(), + operator: FromPromLikeMap[operator] ?? AbstractLabelOperator.Equal, + }); + + return toPromLikeExpr(labels); +} + +export const grammar: Grammar = { + 'context-labels': { + pattern: /\{[^}]*(?=}?)/, + greedy: true, + inside: { + comment: { + pattern: /#.*/, + }, + 'label-key': { + pattern: /[a-zA-Z_]\w*(?=\s*(=|!=|=~|!~))/, + alias: 'attr-name', + greedy: true, + }, + 'label-value': { + pattern: /"(?:\\.|[^\\"])*"/, + greedy: true, + alias: 'attr-value', + }, + punctuation: /[{]/, + }, + }, + punctuation: /[{}(),.]/, +}; From 7505af2886fd08769a2d3c0d966277d770d97270 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Mon, 29 Apr 2024 19:46:19 +0100 Subject: [PATCH 41/53] Chore: Update go-duck dependency to v0.0.18 (#87073) * Chore: Update go-duck dependency to v0.0.18 --- go.mod | 2 +- go.sum | 4 ++-- go.work.sum | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 623c1e4ffa800..0eb181b83cfa2 100644 --- a/go.mod +++ b/go.mod @@ -146,7 +146,7 @@ require ( github.com/redis/go-redis/v9 v9.1.0 // @grafana/alerting-squad-backend github.com/robfig/cron/v3 v3.0.1 // @grafana/grafana-backend-group github.com/russellhaering/goxmldsig v1.4.0 // @grafana/grafana-backend-group - github.com/scottlepp/go-duck v0.0.15 // @grafana/grafana-app-platform-squad + github.com/scottlepp/go-duck v0.0.19 // @grafana/grafana-app-platform-squad github.com/spf13/cobra v1.8.0 // @grafana/grafana-app-platform-squad github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad github.com/spyzhov/ajson v0.9.0 // @grafana/grafana-app-platform-squad diff --git a/go.sum b/go.sum index 71cb467b0e065..69a180ea839c2 100644 --- a/go.sum +++ b/go.sum @@ -2919,8 +2919,8 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.21 h1:yWfiTPwYxB0l5fGMhl/G+liULugVIHD9AU77iNLrURQ= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.21/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= -github.com/scottlepp/go-duck v0.0.15 h1:qrSF3pXlXAA4a7uxAfLYajqXLkeBjv8iW1wPdSfkMj0= -github.com/scottlepp/go-duck v0.0.15/go.mod h1:GL+hHuKdueJRrFCduwBc7A7TQk+Tetc5BPXPVtduihY= +github.com/scottlepp/go-duck v0.0.19 h1:SjO0HF+xe6TN9agMram+CG8+NWKgGMSj8LfqRm0JvpA= +github.com/scottlepp/go-duck v0.0.19/go.mod h1:GL+hHuKdueJRrFCduwBc7A7TQk+Tetc5BPXPVtduihY= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= diff --git a/go.work.sum b/go.work.sum index 2fa7d95e67a69..51b558d5e4a31 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.31.0-20230802163732-1c33ebd9ecfa.1/go.mod h1:xafc+XIsTxTy76GJQ1TKgvJWsSugFBqMaN27WhUblew= buf.build/gen/go/grpc-ecosystem/grpc-gateway/bufbuild/connect-go v1.4.1-20221127060915-a1ecdc58eccd.1 h1:vp9EaPFSb75qe/793x58yE5fY1IJ/gdxb/kcDUzavtI= buf.build/gen/go/grpc-ecosystem/grpc-gateway/bufbuild/connect-go v1.4.1-20221127060915-a1ecdc58eccd.1/go.mod h1:YDq2B5X5BChU0lxAG5MxHpDb8mx1fv9OGtF2mwOe7hY= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.28.1-20221127060915-a1ecdc58eccd.4 h1:z3Xc9n8yZ5k/Xr4ZTuff76TAYP20dWy7ZBV4cGIpbkM= @@ -419,6 +420,7 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH github.com/alecthomas/kong v0.2.11 h1:RKeJXXWfg9N47RYfMm0+igkxBCTF4bzbneAxaqid0c4= github.com/alecthomas/kong v0.2.11/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= +github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI= @@ -450,6 +452,7 @@ github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQ github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/bufbuild/protovalidate-go v0.2.1/go.mod h1:e7XXDtlxj5vlEyAgsrxpzayp4cEMKCSSb8ZCkin+MVA= github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= github.com/casbin/casbin/v2 v2.37.0 h1:/poEwPSovi4bTOcP752/CsTQiRz2xycyVKFG7GUhbDw= @@ -606,6 +609,7 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-yaml v1.11.0 h1:n7Z+zx8S9f9KgzG6KtQKf+kwqXZlLNR2F6018Dgau54= +github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4 h1:vF83LI8tAakwEwvWZtrIEx7pOySacl2TOxx6eXk4ePo= github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= github.com/gofiber/fiber/v2 v2.46.0 h1:wkkWotblsGVlLjXj2dpgKQAYHtXumsK/HyFugQM68Ns= @@ -651,6 +655,7 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWet github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= github.com/hashicorp/go-hclog v0.16.1 h1:IVQwpTGNRRIHafnTs2dQLIk4ENtneRIEEJWOVDqz99o= github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -875,6 +880,8 @@ github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1Avp github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/scottlepp/go-duck v0.0.19 h1:SjO0HF+xe6TN9agMram+CG8+NWKgGMSj8LfqRm0JvpA= +github.com/scottlepp/go-duck v0.0.19/go.mod h1:GL+hHuKdueJRrFCduwBc7A7TQk+Tetc5BPXPVtduihY= github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e h1:uO75wNGioszjmIzcY/tvdDYKRLVvzggtAmmJkn9j4GQ= github.com/segmentio/fasthash v0.0.0-20180216231524-a72b379d632e/go.mod h1:tm/wZFQ8e24NYaBGIlnO2WGCAi67re4HHuOm0sftE/M= github.com/segmentio/parquet-go v0.0.0-20230427215636-d483faba23a5 h1:7CWCjaHrXSUCHrRhIARMGDVKdB82tnPAQMmANeflKOw= @@ -899,10 +906,15 @@ github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAH github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8= +github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg= github.com/tdewolff/minify/v2 v2.12.9 h1:dvn5MtmuQ/DFMwqf5j8QhEVpPX6fi3WGImhv8RUB4zA= github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU= github.com/tdewolff/parse/v2 v2.6.8 h1:mhNZXYCx//xG7Yq2e/kVLNZw4YfYmeHbhx+Zc0OvFMA= github.com/tdewolff/parse/v2 v2.6.8/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI= @@ -1059,6 +1071,7 @@ gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= k8s.io/component-base v0.0.0-20240417101527-62c04b35eff6 h1:WN8Lymy+dCTDHgn4vhUSNIB6U+0sDiv/c9Zdr0UeAnI= k8s.io/component-base v0.0.0-20240417101527-62c04b35eff6/go.mod h1:l0ukbPS0lwFxOzSq5ZqjutzF+5IL2TLp495PswRPSZk= +k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0/IM1Dj+82ZwjfxUP1IxaHE+8= k8s.io/kms v0.29.0/go.mod h1:mB0f9HLxRXeXUfHfn1A7rpwOlzXI1gIWu86z6buNoYA= k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= From e3719471d5daccbad3fcc736144c942f2a4cd4cb Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:02:38 -0600 Subject: [PATCH 42/53] Heatmap: Fix histogram highlighted series (#86955) --- .../plugins/panel/heatmap/renderHistogram.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/public/app/plugins/panel/heatmap/renderHistogram.tsx b/public/app/plugins/panel/heatmap/renderHistogram.tsx index 6169151089cd5..14278fcfcc20f 100644 --- a/public/app/plugins/panel/heatmap/renderHistogram.tsx +++ b/public/app/plugins/panel/heatmap/renderHistogram.tsx @@ -10,11 +10,12 @@ export function renderHistogram( let histCtx = can.current?.getContext('2d'); if (histCtx != null) { + const barsGap = 1; let fromIdx = index; - while (xVals[fromIdx--] === xVals[index]) {} - - fromIdx++; + while (xVals[fromIdx - 1] === xVals[index]) { + fromIdx--; + } let toIdx = fromIdx + yBucketCount; @@ -37,16 +38,14 @@ export function renderHistogram( if (c > 0) { let pctY = c / maxCount; - let pctX = j / (yBucketCount + 1); + let pctX = j / yBucketCount; let p = i === index ? pHov : pRest; - p.rect( - Math.round(histCanWidth * pctX), - Math.round(histCanHeight * (1 - pctY)), - Math.round(histCanWidth / yBucketCount), - Math.round(histCanHeight * pctY) - ); + const xCoord = histCanWidth * pctX + barsGap; + const width = histCanWidth / yBucketCount - barsGap; + + p.rect(xCoord, Math.round(histCanHeight * (1 - pctY)), width, Math.round(histCanHeight * pctY)); } i++; From 8bb9b06e482f032f13316c0502e116f7d7e545d8 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Mon, 29 Apr 2024 22:34:10 +0100 Subject: [PATCH 43/53] Chore: Rewrite grafana-sql css using object styles (#87052) --- .betterer.results | 16 +----- .../query-editor-raw/QueryToolbox.tsx | 50 +++++++++---------- .../query-editor-raw/QueryValidator.tsx | 22 ++++---- .../components/query-editor-raw/RawEditor.tsx | 16 +++--- 4 files changed, 45 insertions(+), 59 deletions(-) diff --git a/.betterer.results b/.betterer.results index 7bd18516f5a92..2c05174492bfe 100644 --- a/.betterer.results +++ b/.betterer.results @@ -723,21 +723,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "6"] ], "packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx:5381": [ - [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"], - [0, 0, 0, "Styles should be written using objects.", "3"], - [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"] - ], - "packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] - ], - "packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] + [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], "packages/grafana-sql/src/components/visual-query-builder/AwesomeQueryBuilder.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx index a8817e102f637..5798b65b56776 100644 --- a/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx +++ b/packages/grafana-sql/src/components/query-editor-raw/QueryToolbox.tsx @@ -20,31 +20,31 @@ export function QueryToolbox({ showTools, onFormatCode, onExpand, isExpanded, .. const styles = useMemo(() => { return { - container: css` - border: 1px solid ${theme.colors.border.medium}; - border-top: none; - padding: ${theme.spacing(0.5, 0.5, 0.5, 0.5)}; - display: flex; - flex-grow: 1; - justify-content: space-between; - font-size: ${theme.typography.bodySmall.fontSize}; - `, - error: css` - color: ${theme.colors.error.text}; - font-size: ${theme.typography.bodySmall.fontSize}; - font-family: ${theme.typography.fontFamilyMonospace}; - `, - valid: css` - color: ${theme.colors.success.text}; - `, - info: css` - color: ${theme.colors.text.secondary}; - `, - hint: css` - color: ${theme.colors.text.disabled}; - white-space: nowrap; - cursor: help; - `, + container: css({ + border: `1px solid ${theme.colors.border.medium}`, + borderTop: 'none', + padding: theme.spacing(0.5, 0.5, 0.5, 0.5), + display: 'flex', + flexGrow: 1, + justifyContent: 'space-between', + fontSize: theme.typography.bodySmall.fontSize, + }), + error: css({ + color: theme.colors.error.text, + fontSize: theme.typography.bodySmall.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + }), + valid: css({ + color: theme.colors.success.text, + }), + info: css({ + color: theme.colors.text.secondary, + }), + hint: css({ + color: theme.colors.text.disabled, + whiteSpace: 'nowrap', + cursor: 'help', + }), }; }, [theme]); diff --git a/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx b/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx index 0cbad6702a8a3..c787229b9d9b4 100644 --- a/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx +++ b/packages/grafana-sql/src/components/query-editor-raw/QueryValidator.tsx @@ -22,17 +22,17 @@ export function QueryValidator({ db, query, onValidate, range }: QueryValidatorP const styles = useMemo(() => { return { - error: css` - color: ${theme.colors.error.text}; - font-size: ${theme.typography.bodySmall.fontSize}; - font-family: ${theme.typography.fontFamilyMonospace}; - `, - valid: css` - color: ${theme.colors.success.text}; - `, - info: css` - color: ${theme.colors.text.secondary}; - `, + error: css({ + color: theme.colors.error.text, + fontSize: theme.typography.bodySmall.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + }), + valid: css({ + color: theme.colors.success.text, + }), + info: css({ + color: theme.colors.text.secondary, + }), }; }, [theme]); diff --git a/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx b/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx index 19215827b7467..0c6883adaa972 100644 --- a/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx +++ b/packages/grafana-sql/src/components/query-editor-raw/RawEditor.tsx @@ -114,13 +114,13 @@ export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryTo function getStyles(theme: GrafanaTheme2) { return { - modal: css` - width: 95vw; - height: 95vh; - `, - modalContent: css` - height: 100%; - padding-top: 0; - `, + modal: css({ + width: '95vw', + height: '95vh', + }), + modalContent: css({ + height: '100%', + paddingTop: 0, + }), }; } From 052082a9273ed995b3862b22fa39b9bfff81a4be Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 29 Apr 2024 21:52:15 -0400 Subject: [PATCH 44/53] Alerting: Refactor Alert Rule Generators (#86813) --- .../loki/historian_store_test.go | 46 +- .../ngalert/accesscontrol/rules_test.go | 42 +- .../ngalert/api/api_prometheus_test.go | 60 ++- .../ngalert/api/api_ruler_export_test.go | 46 +- pkg/services/ngalert/api/api_ruler_test.go | 121 ++--- pkg/services/ngalert/api/api_testing_test.go | 16 +- pkg/services/ngalert/api/util_test.go | 5 +- .../ngalert/backtesting/engine_test.go | 3 +- .../ngalert/models/alert_rule_test.go | 69 +-- pkg/services/ngalert/models/testing.go | 466 ++++++++++++------ pkg/services/ngalert/ngalert_test.go | 3 +- .../provisioning/accesscontrol_test.go | 2 +- .../ngalert/provisioning/alert_rules_test.go | 46 +- .../ngalert/schedule/alert_rule_test.go | 37 +- pkg/services/ngalert/schedule/jitter_test.go | 27 +- .../schedule/loaded_metrics_reader_test.go | 2 +- .../ngalert/schedule/registry_bench_test.go | 7 +- .../ngalert/schedule/registry_test.go | 7 +- .../ngalert/schedule/schedule_unit_test.go | 10 +- .../ngalert/state/cache_bench_test.go | 4 +- pkg/services/ngalert/state/cache_test.go | 3 +- .../state/historian/annotation_test.go | 15 +- .../ngalert/state/manager_private_test.go | 52 +- pkg/services/ngalert/state/manager_test.go | 58 ++- pkg/services/ngalert/state/state_test.go | 3 +- pkg/services/ngalert/store/alert_rule_test.go | 117 ++--- pkg/services/ngalert/store/deltas_test.go | 108 ++-- 27 files changed, 733 insertions(+), 642 deletions(-) diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index a364e9c48bc3b..5202952cda95f 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -7,10 +7,11 @@ import ( "math/rand" "net/url" "strconv" - "sync" "testing" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" @@ -28,7 +29,6 @@ import ( historymodel "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/testsuite" - "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" ) @@ -59,21 +59,15 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { "title": "Dashboard 2", }), }) - - knownUIDs := &sync.Map{} - generator := ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(knownUIDs), - ngmodels.WithUniqueID(), - ngmodels.WithOrgID(1), - ) + gen := ngmodels.RuleGen.With(ngmodels.RuleGen.WithOrgID(1)) dashboardRules := map[string][]*ngmodels.AlertRule{ dashboard1.UID: { - createAlertRuleFromDashboard(t, sql, "Test Rule 1", *dashboard1, generator), - createAlertRuleFromDashboard(t, sql, "Test Rule 2", *dashboard1, generator), + createAlertRuleFromDashboard(t, sql, "Test Rule 1", *dashboard1, gen), + createAlertRuleFromDashboard(t, sql, "Test Rule 2", *dashboard1, gen), }, dashboard2.UID: { - createAlertRuleFromDashboard(t, sql, "Test Rule 3", *dashboard2, generator), + createAlertRuleFromDashboard(t, sql, "Test Rule 3", *dashboard2, gen), }, } @@ -343,7 +337,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { rule := dashboardRules[dashboard1.UID][0] stream1 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) - rule = createAlertRule(t, sql, "Test rule", generator) + rule = createAlertRule(t, sql, "Test rule", gen) stream2 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()) stream := historian.Stream{ @@ -591,14 +585,15 @@ func createTestLokiStore(t *testing.T, sql db.DB, client lokiQueryClient) *LokiH // createAlertRule creates an alert rule in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRule(t *testing.T, sql db.DB, title string, generator func() *ngmodels.AlertRule) *ngmodels.AlertRule { +func createAlertRule(t *testing.T, sql db.DB, title string, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() if generator == nil { - generator = ngmodels.AlertRuleGen(ngmodels.WithTitle(title), withDashboardUID(nil), withPanelID(nil), ngmodels.WithOrgID(1)) + g := ngmodels.RuleGen + generator = g.With(g.WithTitle(title), g.WithDashboardAndPanel(nil, nil), g.WithOrgID(1)) } - rule := generator() + rule := generator.GenerateRef() // ensure rule has correct values if rule.Title != title { rule.Title = title @@ -632,17 +627,18 @@ func createAlertRule(t *testing.T, sql db.DB, title string, generator func() *ng // createAlertRuleFromDashboard creates an alert rule with a linked dashboard and panel in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRuleFromDashboard(t *testing.T, sql db.DB, title string, dashboard dashboards.Dashboard, generator func() *ngmodels.AlertRule) *ngmodels.AlertRule { +func createAlertRuleFromDashboard(t *testing.T, sql db.DB, title string, dashboard dashboards.Dashboard, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() panelID := new(int64) *panelID = 123 if generator == nil { - generator = ngmodels.AlertRuleGen(ngmodels.WithTitle(title), ngmodels.WithOrgID(1), withDashboardUID(&dashboard.UID), withPanelID(panelID)) + g := ngmodels.RuleGen + generator = g.With(g.WithTitle(title), g.WithDashboardAndPanel(&dashboard.UID, panelID), g.WithOrgID(1)) } - rule := generator() + rule := generator.GenerateRef() // ensure rule has correct values if rule.Title != title { rule.Title = title @@ -741,18 +737,6 @@ func genStateTransitions(t *testing.T, num int, start time.Time) []state.StateTr return transitions } -func withDashboardUID(dashboardUID *string) ngmodels.AlertRuleMutator { - return func(rule *ngmodels.AlertRule) { - rule.DashboardUID = dashboardUID - } -} - -func withPanelID(panelID *int64) ngmodels.AlertRuleMutator { - return func(rule *ngmodels.AlertRule) { - rule.PanelID = panelID - } -} - func compareAnnotationItem(t *testing.T, expected, actual *annotations.ItemDTO) { require.Equal(t, expected.AlertID, actual.AlertID) require.Equal(t, expected.AlertName, actual.AlertName) diff --git a/pkg/services/ngalert/accesscontrol/rules_test.go b/pkg/services/ngalert/accesscontrol/rules_test.go index 05301b1c2ae02..d426d0ddd90c6 100644 --- a/pkg/services/ngalert/accesscontrol/rules_test.go +++ b/pkg/services/ngalert/accesscontrol/rules_test.go @@ -92,6 +92,8 @@ func createUserWithPermissions(permissions map[string][]string) identity.Request func TestAuthorizeRuleChanges(t *testing.T) { groupKey := models.GenerateGroupKey(rand.Int63()) namespaceIdScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(groupKey.NamespaceUID) + gen := models.RuleGen + genWithGroupKey := gen.With(gen.WithGroupKey(groupKey)) testCases := []struct { name string @@ -103,7 +105,7 @@ func TestAuthorizeRuleChanges(t *testing.T) { changes: func() *store.GroupDelta { return &store.GroupDelta{ GroupKey: groupKey, - New: models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))), + New: genWithGroupKey.GenerateManyRef(1, 5), Update: nil, Delete: nil, } @@ -132,8 +134,8 @@ func TestAuthorizeRuleChanges(t *testing.T) { { name: "if there are rules to delete it should check delete action and query for datasource", changes: func() *store.GroupDelta { - rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) - rules2 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) + rules := genWithGroupKey.GenerateManyRef(1, 5) + rules2 := genWithGroupKey.GenerateManyRef(1, 5) return &store.GroupDelta{ GroupKey: groupKey, AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ @@ -162,8 +164,8 @@ func TestAuthorizeRuleChanges(t *testing.T) { { name: "if there are rules to update within the same namespace it should check update action and access to datasource", changes: func() *store.GroupDelta { - rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) - rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) + rules1 := genWithGroupKey.GenerateManyRef(1, 5) + rules := genWithGroupKey.GenerateManyRef(1, 5) updates := make([]store.RuleDelta, 0, len(rules)) for _, rule := range rules { @@ -207,18 +209,14 @@ func TestAuthorizeRuleChanges(t *testing.T) { { name: "if there are rules that are moved between namespaces it should check delete+add action and access to group where rules come from", changes: func() *store.GroupDelta { - rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) - rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) + rules1 := genWithGroupKey.GenerateManyRef(1, 5) + rules := genWithGroupKey.GenerateManyRef(1, 5) targetGroupKey := models.GenerateGroupKey(groupKey.OrgID) updates := make([]store.RuleDelta, 0, len(rules)) for _, rule := range rules { - cp := models.CopyRule(rule) - models.WithGroupKey(targetGroupKey)(cp) - cp.Data = []models.AlertQuery{ - models.GenerateAlertQuery(), - } + cp := models.CopyRule(rule, gen.WithGroupKey(targetGroupKey), gen.WithQuery(gen.GenerateQuery())) updates = append(updates, store.RuleDelta{ Existing: rule, @@ -269,8 +267,8 @@ func TestAuthorizeRuleChanges(t *testing.T) { NamespaceUID: groupKey.NamespaceUID, RuleGroup: util.GenerateShortUID(), } - sourceGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(groupKey))) - targetGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithGroupKey(targetGroupKey))) + sourceGroup := genWithGroupKey.GenerateManyRef(1, 5) + targetGroup := gen.With(gen.WithGroupKey(targetGroupKey)).GenerateManyRef(1, 5) updates := make([]store.RuleDelta, 0, len(sourceGroup)) toCopy := len(sourceGroup) @@ -279,11 +277,7 @@ func TestAuthorizeRuleChanges(t *testing.T) { } for i := 0; i < toCopy; i++ { rule := sourceGroup[0] - cp := models.CopyRule(rule) - models.WithGroupKey(targetGroupKey)(cp) - cp.Data = []models.AlertQuery{ - models.GenerateAlertQuery(), - } + cp := models.CopyRule(rule, gen.WithGroupKey(targetGroupKey), gen.WithQuery(models.GenerateAlertQuery())) updates = append(updates, store.RuleDelta{ Existing: rule, @@ -379,7 +373,7 @@ func TestAuthorizeRuleChanges(t *testing.T) { } func TestCheckDatasourcePermissionsForRule(t *testing.T) { - rule := models.AlertRuleGen()() + rule := models.RuleGen.GenerateRef() expressionByType := models.GenerateAlertQuery() expressionByType.QueryType = expr.DatasourceType @@ -442,7 +436,7 @@ func TestCheckDatasourcePermissionsForRule(t *testing.T) { func Test_authorizeAccessToRuleGroup(t *testing.T) { t.Run("should return true if user has access to all datasources of all rules in group", func(t *testing.T) { - rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen()) + rules := models.RuleGen.GenerateManyRef(1, 5) var scopes []string for _, rule := range rules { for _, query := range rule.Data { @@ -470,7 +464,9 @@ func Test_authorizeAccessToRuleGroup(t *testing.T) { }) t.Run("should return false if user does not have access to at least one rule in group", func(t *testing.T) { f := &folder.Folder{UID: "test-folder"} - rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(models.WithNamespace(f))) + gen := models.RuleGen + genWithFolder := gen.With(gen.WithNamespace(f)) + rules := genWithFolder.GenerateManyRef(1, 5) var scopes []string for _, rule := range rules { for _, query := range rule.Data { @@ -487,7 +483,7 @@ func Test_authorizeAccessToRuleGroup(t *testing.T) { datasources.ActionQuery: scopes, } - rule := models.AlertRuleGen(models.WithNamespace(f))() + rule := genWithFolder.GenerateRef() rules = append(rules, rule) ac := &recordingAccessControlFake{} diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 86a3c328f8246..905bc81e99b5c 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "math/rand" "net/http" "testing" "time" @@ -21,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -287,6 +285,8 @@ func TestRouteGetRuleStatuses(t *testing.T) { timeNow = func() time.Time { return time.Date(2022, 3, 10, 14, 0, 0, 0, time.UTC) } orgID := int64(1) + gen := ngmodels.RuleGen + gen = gen.With(gen.WithOrgID(orgID)) queryPermissions := map[int64]map[string][]string{1: {datasources.ActionQuery: {datasources.ScopeAll}}} req, err := http.NewRequest("GET", "/api/v1/rules", nil) @@ -496,7 +496,8 @@ func TestRouteGetRuleStatuses(t *testing.T) { ruleStore := fakes.NewRuleStore(t) fakeAIM := NewFakeAlertInstanceManager(t) groupKey := ngmodels.GenerateGroupKey(orgID) - _, rules := ngmodels.GenerateUniqueAlertRules(rand.Intn(5)+5, ngmodels.AlertRuleGen(withGroupKey(groupKey), ngmodels.WithUniqueGroupIndex())) + gen := ngmodels.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithUniqueGroupIndex()).GenerateManyRef(5, 10) ruleStore.PutRule(context.Background(), rules...) api := PrometheusSrv{ @@ -539,9 +540,9 @@ func TestRouteGetRuleStatuses(t *testing.T) { ruleStore := fakes.NewRuleStore(t) fakeAIM := NewFakeAlertInstanceManager(t) - rules := ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID))) + rules := gen.GenerateManyRef(2, 6) ruleStore.PutRule(context.Background(), rules...) - ruleStore.PutRule(context.Background(), ngmodels.GenerateAlertRules(rand.Intn(4)+2, ngmodels.AlertRuleGen(withOrgID(orgID)))...) + ruleStore.PutRule(context.Background(), gen.GenerateManyRef(2, 6)...) api := PrometheusSrv{ log: log.NewNopLogger(), @@ -575,9 +576,11 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test totals are expected", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) // Create rules in the same Rule Group to keep assertions simple - rules := ngmodels.GenerateAlertRules(3, ngmodels.AlertRuleGen(withOrgID(orgID), withGroup("Rule-Group-1"), withNamespace(&folder.Folder{ - Title: "Folder-1", - }))) + rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + RuleGroup: "Rule-Group-1", + NamespaceUID: "Folder-1", + OrgID: orgID, + })).GenerateManyRef(3) // Need to sort these so we add alerts to the rules as ordered in the response ngmodels.AlertRulesBy(ngmodels.AlertRulesByIndex).Sort(rules) // The last two rules will have errors, however the first will be alerting @@ -635,7 +638,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test time of first firing alert", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) // Create rules in the same Rule Group to keep assertions simple - rules := ngmodels.GenerateAlertRules(1, ngmodels.AlertRuleGen(withOrgID(orgID))) + rules := gen.GenerateManyRef(1) fakeStore.PutRule(context.Background(), rules...) getRuleResponse := func() apimodels.RuleResponse { @@ -691,7 +694,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test with limit on Rule Groups", func(t *testing.T) { fakeStore, _, api := setupAPI(t) - rules := ngmodels.GenerateAlertRules(2, ngmodels.AlertRuleGen(withOrgID(orgID))) + rules := gen.GenerateManyRef(2) fakeStore.PutRule(context.Background(), rules...) t.Run("first without limit", func(t *testing.T) { @@ -763,7 +766,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test with limit rules", func(t *testing.T) { fakeStore, _, api := setupAPI(t) - rules := ngmodels.GenerateAlertRules(2, ngmodels.AlertRuleGen(withOrgID(orgID), withGroup("Rule-Group-1"))) + rules := gen.With(gen.WithGroupName("Rule-Group-1")).GenerateManyRef(2) fakeStore.PutRule(context.Background(), rules...) t.Run("first without limit", func(t *testing.T) { @@ -836,7 +839,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test with limit alerts", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) - rules := ngmodels.GenerateAlertRules(2, ngmodels.AlertRuleGen(withOrgID(orgID), withGroup("Rule-Group-1"))) + rules := gen.With(gen.WithGroupName("Rule-Group-1")).GenerateManyRef(2) fakeStore.PutRule(context.Background(), rules...) // create a normal and firing alert for each rule for _, r := range rules { @@ -927,9 +930,11 @@ func TestRouteGetRuleStatuses(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) // create two rules in the same Rule Group to keep assertions simple - rules := ngmodels.GenerateAlertRules(3, ngmodels.AlertRuleGen(withOrgID(orgID), withGroup("Rule-Group-1"), withNamespace(&folder.Folder{ - Title: "Folder-1", - }))) + rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + NamespaceUID: "Folder-1", + RuleGroup: "Rule-Group-1", + OrgID: orgID, + })).GenerateManyRef(2) // Need to sort these so we add alerts to the rules as ordered in the response ngmodels.AlertRulesBy(ngmodels.AlertRulesByIndex).Sort(rules) // The last two rules will have errors, however the first will be alerting @@ -1084,9 +1089,11 @@ func TestRouteGetRuleStatuses(t *testing.T) { t.Run("test with matcher on labels", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) // create two rules in the same Rule Group to keep assertions simple - rules := ngmodels.GenerateAlertRules(1, ngmodels.AlertRuleGen(withOrgID(orgID), withGroup("Rule-Group-1"), withNamespace(&folder.Folder{ - Title: "Folder-1", - }))) + rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + NamespaceUID: "Folder-1", + RuleGroup: "Rule-Group-1", + OrgID: orgID, + })).GenerateManyRef(1) fakeStore.PutRule(context.Background(), rules...) // create a normal and alerting state for each rule @@ -1268,12 +1275,13 @@ func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, Promet return fakeStore, fakeAIM, api } -func generateRuleAndInstanceWithQuery(t *testing.T, orgID int64, fakeAIM *fakeAlertInstanceManager, fakeStore *fakes.RuleStore, query func(r *ngmodels.AlertRule)) { +func generateRuleAndInstanceWithQuery(t *testing.T, orgID int64, fakeAIM *fakeAlertInstanceManager, fakeStore *fakes.RuleStore, query ngmodels.AlertRuleMutator) { t.Helper() - rules := ngmodels.GenerateAlertRules(1, ngmodels.AlertRuleGen(withOrgID(orgID), asFixture(), query)) + gen := ngmodels.RuleGen + r := gen.With(gen.WithOrgID(orgID), asFixture(), query).GenerateRef() - fakeAIM.GenerateAlertInstances(orgID, rules[0].UID, 1, func(s *state.State) *state.State { + fakeAIM.GenerateAlertInstances(orgID, r.UID, 1, func(s *state.State) *state.State { s.Labels = data.Labels{ "job": "prometheus", alertingModels.NamespaceUIDLabel: "test_namespace_uid", @@ -1283,14 +1291,12 @@ func generateRuleAndInstanceWithQuery(t *testing.T, orgID int64, fakeAIM *fakeAl return s }) - for _, r := range rules { - fakeStore.PutRule(context.Background(), r) - } + fakeStore.PutRule(context.Background(), r) } // asFixture removes variable values of the alert rule. // we're not too interested in variability of the rule in this scenario. -func asFixture() func(r *ngmodels.AlertRule) { +func asFixture() ngmodels.AlertRuleMutator { return func(r *ngmodels.AlertRule) { r.Title = "AlwaysFiring" r.NamespaceUID = "namespaceUID" @@ -1306,7 +1312,7 @@ func asFixture() func(r *ngmodels.AlertRule) { } } -func withClassicConditionSingleQuery() func(r *ngmodels.AlertRule) { +func withClassicConditionSingleQuery() ngmodels.AlertRuleMutator { return func(r *ngmodels.AlertRule) { queries := []ngmodels.AlertQuery{ { @@ -1328,7 +1334,7 @@ func withClassicConditionSingleQuery() func(r *ngmodels.AlertRule) { } } -func withExpressionsMultiQuery() func(r *ngmodels.AlertRule) { +func withExpressionsMultiQuery() ngmodels.AlertRuleMutator { return func(r *ngmodels.AlertRule) { queries := []ngmodels.AlertQuery{ { diff --git a/pkg/services/ngalert/api/api_ruler_export_test.go b/pkg/services/ngalert/api/api_ruler_export_test.go index ae91fb0389afe..b1281045a00d0 100644 --- a/pkg/services/ngalert/api/api_ruler_export_test.go +++ b/pkg/services/ngalert/api/api_ruler_export_test.go @@ -9,7 +9,6 @@ import ( "path" "sort" "strings" - "sync" "testing" "github.com/stretchr/testify/assert" @@ -198,7 +197,6 @@ func TestExportFromPayload(t *testing.T) { } func TestExportRules(t *testing.T) { - uids := sync.Map{} orgID := int64(1) f1 := randFolder() f2 := randFolder() @@ -210,33 +208,20 @@ func TestExportRules(t *testing.T) { NamespaceUID: f1.UID, RuleGroup: "HAS-ACCESS-1", } - accessQuery := ngmodels.GenerateAlertQuery() - noAccessQuery := ngmodels.GenerateAlertQuery() - - _, hasAccess1 := ngmodels.GenerateUniqueAlertRules(5, - ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(&uids), - withGroupKey(hasAccessKey1), - ngmodels.WithQuery(accessQuery), - ngmodels.WithUniqueGroupIndex(), - )) + + gen := ngmodels.RuleGen + accessQuery := gen.GenerateQuery() + noAccessQuery := gen.GenerateQuery() + + hasAccess1 := gen.With(gen.WithGroupKey(hasAccessKey1), gen.WithQuery(accessQuery), gen.WithUniqueGroupIndex()).GenerateManyRef(5) ruleStore.PutRule(context.Background(), hasAccess1...) noAccessKey1 := ngmodels.AlertRuleGroupKey{ OrgID: orgID, NamespaceUID: f1.UID, RuleGroup: "NO-ACCESS", } - _, noAccess1 := ngmodels.GenerateUniqueAlertRules(5, - ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(&uids), - withGroupKey(noAccessKey1), - ngmodels.WithQuery(noAccessQuery), - )) - noAccessRule := ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(&uids), - withGroupKey(noAccessKey1), - ngmodels.WithQuery(accessQuery), - )() + noAccess1 := gen.With(gen.WithGroupKey(noAccessKey1), gen.WithQuery(noAccessQuery)).GenerateManyRef(5) + noAccessRule := gen.With(gen.WithGroupKey(noAccessKey1), gen.WithQuery(accessQuery)).GenerateRef() noAccess1 = append(noAccess1, noAccessRule) ruleStore.PutRule(context.Background(), noAccess1...) @@ -245,21 +230,10 @@ func TestExportRules(t *testing.T) { NamespaceUID: f2.UID, RuleGroup: "HAS-ACCESS-2", } - _, hasAccess2 := ngmodels.GenerateUniqueAlertRules(5, - ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(&uids), - withGroupKey(hasAccessKey2), - ngmodels.WithQuery(accessQuery), - ngmodels.WithUniqueGroupIndex(), - )) + hasAccess2 := gen.With(gen.WithGroupKey(hasAccessKey2), gen.WithQuery(accessQuery), gen.WithUniqueGroupIndex()).GenerateManyRef(5) ruleStore.PutRule(context.Background(), hasAccess2...) - _, noAccessByFolder := ngmodels.GenerateUniqueAlertRules(10, - ngmodels.AlertRuleGen( - ngmodels.WithUniqueUID(&uids), - ngmodels.WithQuery(accessQuery), // no access because of folder - ngmodels.WithNamespaceUIDNotIn(f1.UID, f2.UID), - )) + noAccessByFolder := gen.With(gen.WithQuery(accessQuery), gen.WithNamespaceUIDNotIn(f1.UID, f2.UID)).GenerateManyRef(10) ruleStore.PutRule(context.Background(), noAccessByFolder...) // overwrite the folders visible to user because PutRule automatically creates folders in the fake store. diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 7856e85808886..99c109c00ca89 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -33,7 +33,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/cmputil" "github.com/grafana/grafana/pkg/web" ) @@ -67,12 +66,13 @@ func TestRouteDeleteAlertRules(t *testing.T) { orgID := rand.Int63() folder := randFolder() + gen := models.RuleGen.With(models.RuleGen.WithOrgID(orgID)) initFakeRuleStore := func(t *testing.T) *fakes.RuleStore { ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) // add random data - ruleStore.PutRule(context.Background(), models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID)))...) + ruleStore.PutRule(context.Background(), gen.GenerateManyRef(1, 5)...) return ruleStore } @@ -80,7 +80,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { t.Run("and group argument is empty", func(t *testing.T) { t.Run("return Forbidden if user is not authorized to access any group in the folder", func(t *testing.T) { ruleStore := initFakeRuleStore(t) - ruleStore.PutRule(context.Background(), models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ruleStore.PutRule(context.Background(), gen.With(gen.WithNamespace(folder)).GenerateManyRef(1, 5)...) request := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil) @@ -93,16 +93,20 @@ func TestRouteDeleteAlertRules(t *testing.T) { ruleStore := initFakeRuleStore(t) provisioningStore := fakes.NewFakeProvisioningStore() - authorizedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup("authz_"+util.GenerateShortUID()))) + folderGen := gen.With(gen.WithNamespace(folder)) - provisionedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup("provisioned_"+util.GenerateShortUID()))) - err := provisioningStore.SetProvenance(context.Background(), provisionedRulesInFolder[0], orgID, models.ProvenanceAPI) - require.NoError(t, err) + authorizedRulesInFolder := folderGen.With(gen.WithGroupPrefix("authz-")).GenerateManyRef(1, 5) + + provisionedRulesInFolder := folderGen.With(gen.WithGroupPrefix("provisioned-")).GenerateManyRef(1, 5) + for _, rule := range provisionedRulesInFolder { + err := provisioningStore.SetProvenance(context.Background(), rule, orgID, models.ProvenanceAPI) + require.NoError(t, err) + } ruleStore.PutRule(context.Background(), authorizedRulesInFolder...) ruleStore.PutRule(context.Background(), provisionedRulesInFolder...) // more rules in the same namespace but user does not have access to them - ruleStore.PutRule(context.Background(), models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup("unauthz"+util.GenerateShortUID())))...) + ruleStore.PutRule(context.Background(), folderGen.With(gen.WithGroupPrefix("unauthz")).GenerateManyRef(1, 5)...) permissions := createPermissionsForRules(append(authorizedRulesInFolder, provisionedRulesInFolder...), orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) @@ -116,13 +120,15 @@ func TestRouteDeleteAlertRules(t *testing.T) { ruleStore := initFakeRuleStore(t) provisioningStore := fakes.NewFakeProvisioningStore() - provisionedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(util.GenerateShortUID()))) + folderGen := gen.With(gen.WithNamespace(folder)) + + provisionedRulesInFolder := folderGen.With(gen.WithSameGroup()).GenerateManyRef(1, 5) err := provisioningStore.SetProvenance(context.Background(), provisionedRulesInFolder[0], orgID, models.ProvenanceAPI) require.NoError(t, err) ruleStore.PutRule(context.Background(), provisionedRulesInFolder...) // more rules in the same namespace but user does not have access to them - ruleStore.PutRule(context.Background(), models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(util.GenerateShortUID())))...) + ruleStore.PutRule(context.Background(), folderGen.With(gen.WithSameGroup()).GenerateManyRef(1, 5)...) permissions := createPermissionsForRules(provisionedRulesInFolder, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) @@ -143,19 +149,20 @@ func TestRouteDeleteAlertRules(t *testing.T) { }) }) t.Run("and group argument is not empty", func(t *testing.T) { - groupName := util.GenerateShortUID() t.Run("return Forbidden if user is not authorized to access the group", func(t *testing.T) { ruleStore := initFakeRuleStore(t) - authorizedRulesInGroup := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) + groupGen := gen.With(gen.WithNamespace(folder), gen.WithSameGroup()) + + authorizedRulesInGroup := groupGen.GenerateManyRef(1, 5) ruleStore.PutRule(context.Background(), authorizedRulesInGroup...) // more rules in the same group but user is not authorized to access them - ruleStore.PutRule(context.Background(), models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName)))...) + ruleStore.PutRule(context.Background(), groupGen.GenerateManyRef(1, 5)...) permissions := createPermissionsForRules(authorizedRulesInGroup, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName) + response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, authorizedRulesInGroup[0].RuleGroup) require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) deleteCommands := getRecordedCommand(ruleStore) @@ -165,7 +172,9 @@ func TestRouteDeleteAlertRules(t *testing.T) { ruleStore := initFakeRuleStore(t) provisioningStore := fakes.NewFakeProvisioningStore() - provisionedRulesInFolder := models.GenerateAlertRulesSmallNonEmpty(models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) + groupGen := gen.With(gen.WithNamespace(folder), gen.WithSameGroup()) + + provisionedRulesInFolder := groupGen.GenerateManyRef(1, 5) err := provisioningStore.SetProvenance(context.Background(), provisionedRulesInFolder[0], orgID, models.ProvenanceAPI) require.NoError(t, err) @@ -174,7 +183,7 @@ func TestRouteDeleteAlertRules(t *testing.T) { permissions := createPermissionsForRules(provisionedRulesInFolder, orgID) requestCtx := createRequestContextWithPerms(orgID, permissions, nil) - response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName) + response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, provisionedRulesInFolder[0].RuleGroup) require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body())) deleteCommands := getRecordedCommand(ruleStore) @@ -185,15 +194,17 @@ func TestRouteDeleteAlertRules(t *testing.T) { } func TestRouteGetNamespaceRulesConfig(t *testing.T) { + gen := models.RuleGen t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should return rules for which user has access to data source", func(t *testing.T) { orgID := rand.Int63() folder := randFolder() ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) - expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + folderGen := gen.With(gen.WithOrgID(orgID), gen.WithNamespace(folder)) + expectedRules := folderGen.GenerateManyRef(2, 6) ruleStore.PutRule(context.Background(), expectedRules...) - ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ruleStore.PutRule(context.Background(), folderGen.GenerateManyRef(2, 6)...) permissions := createPermissionsForRules(expectedRules, orgID) req := createRequestContextWithPerms(orgID, permissions, nil) @@ -227,7 +238,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { folder := randFolder() ruleStore := fakes.NewRuleStore(t) ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) - expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + expectedRules := gen.With(gen.WithOrgID(orgID), gen.WithNamespace(folder)).GenerateManyRef(2, 6) ruleStore.PutRule(context.Background(), expectedRules...) svc := createService(ruleStore) @@ -271,7 +282,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.UID - expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) + expectedRules := gen.With(gen.WithGroupKey(groupKey), gen.WithUniqueGroupIndex()).GenerateManyRef(5, 10) ruleStore.PutRule(context.Background(), expectedRules...) perms := createPermissionsForRules(expectedRules, orgID) @@ -308,6 +319,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) { } func TestRouteGetRulesConfig(t *testing.T) { + gen := models.RuleGen t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should check access to data source", func(t *testing.T) { orgID := rand.Int63() @@ -321,8 +333,8 @@ func TestRouteGetRulesConfig(t *testing.T) { group2Key := models.GenerateGroupKey(orgID) group2Key.NamespaceUID = folder2.UID - group1 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group1Key))) - group2 := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(group2Key))) + group1 := gen.With(gen.WithGroupKey(group1Key)).GenerateManyRef(2, 6) + group2 := gen.With(gen.WithGroupKey(group2Key)).GenerateManyRef(2, 6) ruleStore.PutRule(context.Background(), append(group1, group2...)...) t.Run("and do not return group if user does not have access to one of rules", func(t *testing.T) { @@ -355,7 +367,7 @@ func TestRouteGetRulesConfig(t *testing.T) { groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.UID - expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) + expectedRules := gen.With(gen.WithGroupKey(groupKey), gen.WithUniqueGroupIndex()).GenerateManyRef(5, 10) ruleStore.PutRule(context.Background(), expectedRules...) perms := createPermissionsForRules(expectedRules, orgID) @@ -392,6 +404,7 @@ func TestRouteGetRulesConfig(t *testing.T) { } func TestRouteGetRulesGroupConfig(t *testing.T) { + gen := models.RuleGen t.Run("fine-grained access is enabled", func(t *testing.T) { t.Run("should check access to data source", func(t *testing.T) { orgID := rand.Int63() @@ -401,7 +414,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.UID - expectedRules := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))) + expectedRules := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(2, 6) ruleStore.PutRule(context.Background(), expectedRules...) t.Run("and return Forbidden if user does not have access one of rules", func(t *testing.T) { @@ -439,7 +452,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) { groupKey := models.GenerateGroupKey(orgID) groupKey.NamespaceUID = folder.UID - expectedRules := models.GenerateAlertRules(rand.Intn(5)+5, models.AlertRuleGen(withGroupKey(groupKey), models.WithUniqueGroupIndex())) + expectedRules := gen.With(gen.WithGroupKey(groupKey), gen.WithUniqueGroupIndex()).GenerateManyRef(5, 10) ruleStore.PutRule(context.Background(), expectedRules...) perms := createPermissionsForRules(expectedRules, orgID) @@ -475,14 +488,15 @@ func TestVerifyProvisionedRulesNotAffected(t *testing.T) { orgID := rand.Int63() group := models.GenerateGroupKey(orgID) affectedGroups := make(map[models.AlertRuleGroupKey]models.RulesGroup) + gen := models.RuleGen var allRules []*models.AlertRule { - rules := models.GenerateAlertRules(rand.Intn(3)+1, models.AlertRuleGen(withGroupKey(group))) + rules := gen.With(gen.WithGroupKey(group)).GenerateManyRef(1, 4) allRules = append(allRules, rules...) affectedGroups[group] = rules for i := 0; i < rand.Intn(3)+1; i++ { g := models.GenerateGroupKey(orgID) - rules := models.GenerateAlertRules(rand.Intn(3)+1, models.AlertRuleGen(withGroupKey(g))) + rules := gen.With(gen.WithGroupKey(g)).GenerateManyRef(1, 4) allRules = append(allRules, rules...) affectedGroups[g] = rules } @@ -533,20 +547,15 @@ func TestVerifyProvisionedRulesNotAffected(t *testing.T) { } func TestValidateQueries(t *testing.T) { + gen := models.RuleGen delta := store.GroupDelta{ New: []*models.AlertRule{ - models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "New" - })(), + gen.With(gen.WithCondition("New")).GenerateRef(), }, Update: []store.RuleDelta{ { - Existing: models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "Update_Existing" - })(), - New: models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "Update_New" - })(), + Existing: gen.With(gen.WithCondition("New")).GenerateRef(), + New: gen.With(gen.WithCondition("Update_New")).GenerateRef(), Diff: cmputil.DiffReport{ cmputil.Diff{ Path: "SomeField", @@ -554,12 +563,8 @@ func TestValidateQueries(t *testing.T) { }, }, { - Existing: models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "Update_Index_Existing" - })(), - New: models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "Update_Index_New" - })(), + Existing: gen.With(gen.WithCondition("Update_Index_Existing")).GenerateRef(), + New: gen.With(gen.WithCondition("Update_Index_New")).GenerateRef(), Diff: cmputil.DiffReport{ cmputil.Diff{ Path: "RuleGroupIndex", @@ -567,11 +572,7 @@ func TestValidateQueries(t *testing.T) { }, }, }, - Delete: []*models.AlertRule{ - models.AlertRuleGen(func(rule *models.AlertRule) { - rule.Condition = "Deleted" - })(), - }, + Delete: gen.With(gen.WithCondition("Deleted")).GenerateManyRef(1), } t.Run("should not validate deleted rules or updated rules with ignored fields", func(t *testing.T) { @@ -694,29 +695,3 @@ func createPermissionsForRules(rules []*models.AlertRule, orgID int64) map[int64 } return map[int64]map[string][]string{orgID: permissions} } - -func withOrgID(orgId int64) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.OrgID = orgId - } -} - -func withGroup(groupName string) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.RuleGroup = groupName - } -} - -func withNamespace(namespace *folder.Folder) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.NamespaceUID = namespace.UID - } -} - -func withGroupKey(groupKey models.AlertRuleGroupKey) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.RuleGroup = groupKey.RuleGroup - rule.OrgID = groupKey.OrgID - rule.NamespaceUID = groupKey.NamespaceUID - } -} diff --git a/pkg/services/ngalert/api/api_testing_test.go b/pkg/services/ngalert/api/api_testing_test.go index becad5a73cc70..a8bee7838ddc8 100644 --- a/pkg/services/ngalert/api/api_testing_test.go +++ b/pkg/services/ngalert/api/api_testing_test.go @@ -174,8 +174,9 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { }) t.Run("should return Forbidden if user cannot query a data source", func(t *testing.T) { - data1 := models.GenerateAlertQuery() - data2 := models.GenerateAlertQuery() + gen := models.RuleGen + data1 := gen.GenerateQuery() + data2 := gen.GenerateQuery() ac := acMock.New().WithPermissions([]ac.Permission{ {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, @@ -195,12 +196,14 @@ func TestRouteTestGrafanaRuleConfig(t *testing.T) { NamespaceTitle: f.Title, }) + t.Log(string(response.Body())) require.Equal(t, http.StatusForbidden, response.Status()) }) t.Run("should return 200 if user can query all data sources", func(t *testing.T) { - data1 := models.GenerateAlertQuery() - data2 := models.GenerateAlertQuery() + gen := models.RuleGen + data1 := gen.GenerateQuery() + data2 := gen.GenerateQuery() ac := acMock.New().WithPermissions([]ac.Permission{ {Action: datasources.ActionQuery, Scope: datasources.ScopeProvider.GetResourceScopeUID(data1.DatasourceUID)}, @@ -252,8 +255,9 @@ func TestRouteEvalQueries(t *testing.T) { } t.Run("should return Forbidden if user cannot query a data source", func(t *testing.T) { - data1 := models.GenerateAlertQuery() - data2 := models.GenerateAlertQuery() + g := models.RuleGen + data1 := g.GenerateQuery() + data2 := g.GenerateQuery() srv := &TestingApiSrv{ authz: accesscontrol.NewRuleService(acMock.New().WithPermissions([]ac.Permission{ diff --git a/pkg/services/ngalert/api/util_test.go b/pkg/services/ngalert/api/util_test.go index 55f5731aa5cd2..e700898084d87 100644 --- a/pkg/services/ngalert/api/util_test.go +++ b/pkg/services/ngalert/api/util_test.go @@ -143,15 +143,16 @@ func TestAlertingProxy_createProxyContext(t *testing.T) { } func Test_containsProvisionedAlerts(t *testing.T) { + gen := models2.RuleGen t.Run("should return true if at least one rule is provisioned", func(t *testing.T) { - _, rules := models2.GenerateUniqueAlertRules(rand.Intn(4)+2, models2.AlertRuleGen()) + rules := gen.GenerateManyRef(2, 6) provenance := map[string]models2.Provenance{ rules[rand.Intn(len(rules))].UID: []models2.Provenance{models2.ProvenanceAPI, models2.ProvenanceFile}[rand.Intn(2)], } require.Truef(t, containsProvisionedAlerts(provenance, rules), "the group of rules is expected to be considered as provisioned but it isn't. Provenances: %v", provenance) }) t.Run("should return false if map does not contain or has ProvenanceNone", func(t *testing.T) { - _, rules := models2.GenerateUniqueAlertRules(rand.Intn(5)+1, models2.AlertRuleGen()) + rules := gen.GenerateManyRef(1, 6) provenance := make(map[string]models2.Provenance) numProvenanceNone := rand.Intn(len(rules)) for i := 0; i < numProvenanceNone; i++ { diff --git a/pkg/services/ngalert/backtesting/engine_test.go b/pkg/services/ngalert/backtesting/engine_test.go index f8d058f141026..26bb317aaad9d 100644 --- a/pkg/services/ngalert/backtesting/engine_test.go +++ b/pkg/services/ngalert/backtesting/engine_test.go @@ -189,7 +189,8 @@ func TestEvaluatorTest(t *testing.T) { return manager }, } - rule := models.AlertRuleGen(models.WithInterval(time.Second))() + gen := models.RuleGen + rule := gen.With(gen.WithInterval(time.Second)).GenerateRef() ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second t.Run("should return data frame in specific format", func(t *testing.T) { diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index f875a73d9d721..414507582f594 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -197,11 +197,10 @@ func TestSetDashboardAndPanelFromAnnotations(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - rule := AlertRuleGen(func(rule *AlertRule) { - rule.Annotations = tc.annotations - rule.DashboardUID = nil - rule.PanelID = nil - })() + rule := RuleGen.With( + RuleMuts.WithDashboardAndPanel(nil, nil), + RuleMuts.WithAnnotations(tc.annotations), + ).Generate() err := rule.SetDashboardAndPanelFromAnnotations() require.Equal(t, tc.expectedError, err) @@ -256,14 +255,16 @@ func TestPatchPartialAlertRule(t *testing.T) { }, } + gen := RuleGen.With( + RuleMuts.WithFor(time.Duration(rand.Int63n(1000) + 1)), + ) + for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { var existing *AlertRuleWithOptionals - for { - rule := AlertRuleGen(func(rule *AlertRule) { - rule.For = time.Duration(rand.Int63n(1000) + 1) - })() - existing = &AlertRuleWithOptionals{AlertRule: *rule} + for i := 0; i < 10; i++ { + rule := gen.Generate() + existing = &AlertRuleWithOptionals{AlertRule: rule} cloned := *existing testCase.mutator(&cloned) if !cmp.Equal(existing, cloned, cmp.FilterPath(func(path cmp.Path) bool { @@ -343,15 +344,20 @@ func TestPatchPartialAlertRule(t *testing.T) { }, } + gen := RuleGen.With( + RuleMuts.WithUniqueID(), + RuleMuts.WithFor(time.Duration(rand.Int63n(1000)+1)), + ) + for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { var existing *AlertRule for { - existing = AlertRuleGen(WithUniqueID())() - cloned := *existing + existing = gen.GenerateRef() + cloned := CopyRule(existing) // make sure the generated rule does not match the mutated one - testCase.mutator(&cloned) - if !cmp.Equal(*existing, cloned, cmp.FilterPath(func(path cmp.Path) bool { + testCase.mutator(cloned) + if !cmp.Equal(existing, cloned, cmp.FilterPath(func(path cmp.Path) bool { return path.String() == "Data.modelProps" }, cmp.Ignore())) { break @@ -368,14 +374,14 @@ func TestPatchPartialAlertRule(t *testing.T) { func TestDiff(t *testing.T) { t.Run("should return nil if there is no diff", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule2 := CopyRule(rule1) result := rule1.Diff(rule2) require.Emptyf(t, result, "expected diff to be empty. rule1: %#v, rule2: %#v\ndiff: %s", rule1, rule2, result) }) t.Run("should respect fields to ignore", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule2 := CopyRule(rule1) rule2.ID = rule1.ID/2 + 1 rule2.Version = rule1.Version/2 + 1 @@ -385,8 +391,8 @@ func TestDiff(t *testing.T) { }) t.Run("should find diff in simple fields", func(t *testing.T) { - rule1 := AlertRuleGen()() - rule2 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() + rule2 := RuleGen.GenerateRef() diffs := rule1.Diff(rule2, "Data", "Annotations", "Labels", "NotificationSettings") // these fields will be tested separately @@ -508,7 +514,7 @@ func TestDiff(t *testing.T) { }) t.Run("should not see difference between nil and empty Annotations", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule1.Annotations = make(map[string]string) rule2 := CopyRule(rule1) rule2.Annotations = nil @@ -518,7 +524,7 @@ func TestDiff(t *testing.T) { }) t.Run("should detect changes in Annotations", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule2 := CopyRule(rule1) rule1.Annotations = map[string]string{ @@ -555,7 +561,7 @@ func TestDiff(t *testing.T) { }) t.Run("should not see difference between nil and empty Labels", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule1.Annotations = make(map[string]string) rule2 := CopyRule(rule1) rule2.Annotations = nil @@ -565,7 +571,7 @@ func TestDiff(t *testing.T) { }) t.Run("should detect changes in Labels", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule2 := CopyRule(rule1) rule1.Labels = map[string]string{ @@ -602,7 +608,7 @@ func TestDiff(t *testing.T) { }) t.Run("should detect changes in Data", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() rule2 := CopyRule(rule1) query1 := AlertQuery{ @@ -658,11 +664,11 @@ func TestDiff(t *testing.T) { t.Run("should correctly detect no change with '<' and '>' in query", func(t *testing.T) { old := query1 - new := query1 + newQuery := query1 old.Model = json.RawMessage(`{"field1": "$A \u003c 1"}`) - new.Model = json.RawMessage(`{"field1": "$A < 1"}`) + newQuery.Model = json.RawMessage(`{"field1": "$A < 1"}`) rule1.Data = []AlertQuery{old} - rule2.Data = []AlertQuery{new} + rule2.Data = []AlertQuery{newQuery} diff := rule1.Diff(rule2) assert.Nil(t, diff) @@ -699,7 +705,7 @@ func TestDiff(t *testing.T) { }) t.Run("should detect changes in NotificationSettings", func(t *testing.T) { - rule1 := AlertRuleGen()() + rule1 := RuleGen.GenerateRef() baseSettings := NotificationSettingsGen(NSMuts.WithGroupBy("test1", "test2"))() rule1.NotificationSettings = []NotificationSettings{baseSettings} @@ -824,7 +830,9 @@ func TestSortByGroupIndex(t *testing.T) { } t.Run("should sort rules by GroupIndex", func(t *testing.T) { - rules := GenerateAlertRules(rand.Intn(15)+5, AlertRuleGen(WithUniqueGroupIndex())) + rules := RuleGen.With( + RuleMuts.WithUniqueGroupIndex(), + ).GenerateManyRef(5, 20) ensureNotSorted(t, rules, func(i, j int) bool { return rules[i].RuleGroupIndex < rules[j].RuleGroupIndex }) @@ -835,7 +843,10 @@ func TestSortByGroupIndex(t *testing.T) { }) t.Run("should sort by ID if same GroupIndex", func(t *testing.T) { - rules := GenerateAlertRules(rand.Intn(15)+5, AlertRuleGen(WithUniqueID(), WithGroupIndex(rand.Int()))) + rules := RuleGen.With( + RuleMuts.WithUniqueID(), + RuleMuts.WithGroupIndex(rand.Int()), + ).GenerateManyRef(5, 20) ensureNotSorted(t, rules, func(i, j int) bool { return rules[i].ID < rules[j].ID }) diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index f22f357f25192..27deff6c00074 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" @@ -20,222 +19,312 @@ import ( "github.com/grafana/grafana/pkg/util" ) -type AlertRuleMutator func(*AlertRule) - -// AlertRuleGen provides a factory function that generates a random AlertRule. -// The mutators arguments allows changing fields of the resulting structure -func AlertRuleGen(mutators ...AlertRuleMutator) func() *AlertRule { - return func() *AlertRule { - randNoDataState := func() NoDataState { - s := [...]NoDataState{ - Alerting, - NoData, - OK, - } - return s[rand.Intn(len(s))] - } +var ( + RuleMuts = AlertRuleMutators{} + NSMuts = NotificationSettingsMutators{} + RuleGen = &AlertRuleGenerator{ + mutators: []AlertRuleMutator{ + RuleMuts.WithUniqueUID(), RuleMuts.WithUniqueTitle(), + }, + } +) - randErrState := func() ExecutionErrorState { - s := [...]ExecutionErrorState{ - AlertingErrState, - ErrorErrState, - OkErrState, - } - return s[rand.Intn(len(s))] - } +type AlertRuleMutator func(r *AlertRule) - interval := (rand.Int63n(6) + 1) * 10 - forInterval := time.Duration(interval*rand.Int63n(6)) * time.Second +type AlertRuleGenerator struct { + AlertRuleMutators + mutators []AlertRuleMutator +} - var annotations map[string]string = nil - if rand.Int63()%2 == 0 { - annotations = GenerateAlertLabels(rand.Intn(5), "ann-") - } - var labels map[string]string = nil - if rand.Int63()%2 == 0 { - labels = GenerateAlertLabels(rand.Intn(5), "lbl-") - } +func (g *AlertRuleGenerator) With(mutators ...AlertRuleMutator) *AlertRuleGenerator { + return &AlertRuleGenerator{ + AlertRuleMutators: g.AlertRuleMutators, + mutators: append(g.mutators, mutators...), + } +} - var dashUID *string = nil - var panelID *int64 = nil - if rand.Int63()%2 == 0 { - d := util.GenerateShortUID() - dashUID = &d - p := rand.Int63n(1500) - panelID = &p +func (g *AlertRuleGenerator) Generate() AlertRule { + randNoDataState := func() NoDataState { + s := [...]NoDataState{ + Alerting, + NoData, + OK, } + return s[rand.Intn(len(s))] + } - var ns []NotificationSettings - if rand.Int63()%2 == 0 { - ns = append(ns, NotificationSettingsGen()()) + randErrState := func() ExecutionErrorState { + s := [...]ExecutionErrorState{ + AlertingErrState, + ErrorErrState, + OkErrState, } + return s[rand.Intn(len(s))] + } - rule := &AlertRule{ - ID: 0, - OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation. - Title: "TEST-ALERT-" + util.GenerateShortUID(), - Condition: "A", - Data: []AlertQuery{GenerateAlertQuery()}, - Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), - IntervalSeconds: rand.Int63n(60) + 1, - Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres - UID: util.GenerateShortUID(), - NamespaceUID: util.GenerateShortUID(), - DashboardUID: dashUID, - PanelID: panelID, - RuleGroup: "TEST-GROUP-" + util.GenerateShortUID(), - RuleGroupIndex: rand.Intn(1500), - NoDataState: randNoDataState(), - ExecErrState: randErrState(), - For: forInterval, - Annotations: annotations, - Labels: labels, - NotificationSettings: ns, - } + interval := (rand.Int63n(6) + 1) * 10 + forInterval := time.Duration(interval*rand.Int63n(6)) * time.Second - for _, mutator := range mutators { - mutator(rule) + var annotations map[string]string = nil + if rand.Int63()%2 == 0 { + annotations = GenerateAlertLabels(rand.Intn(5), "ann-") + } + var labels map[string]string = nil + if rand.Int63()%2 == 0 { + labels = GenerateAlertLabels(rand.Intn(5), "lbl-") + } + + var dashUID *string = nil + var panelID *int64 = nil + if rand.Int63()%2 == 0 { + d := util.GenerateShortUID() + dashUID = &d + p := rand.Int63n(1500) + panelID = &p + } + + var ns []NotificationSettings + if rand.Int63()%2 == 0 { + ns = append(ns, NotificationSettingsGen()()) + } + + rule := AlertRule{ + ID: 0, + OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation. + Title: fmt.Sprintf("title-%s", util.GenerateShortUID()), + Condition: "A", + Data: []AlertQuery{g.GenerateQuery()}, + Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)), + IntervalSeconds: rand.Int63n(60) + 1, + Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres + UID: util.GenerateShortUID(), + NamespaceUID: util.GenerateShortUID(), + DashboardUID: dashUID, + PanelID: panelID, + RuleGroup: fmt.Sprintf("group-%s,", util.GenerateShortUID()), + RuleGroupIndex: rand.Intn(1500), + NoDataState: randNoDataState(), + ExecErrState: randErrState(), + For: forInterval, + Annotations: annotations, + Labels: labels, + NotificationSettings: ns, + } + + for _, mutator := range g.mutators { + mutator(&rule) + } + return rule +} + +func (g *AlertRuleGenerator) GenerateRef() *AlertRule { + r := g.Generate() + return &r +} + +func (g *AlertRuleGenerator) getCount(bounds ...int) int { + count := 0 + if len(bounds) == 0 { + count = rand.Intn(5) + 1 + } + if len(bounds) == 1 { + count = bounds[0] + } + if len(bounds) == 2 { + if bounds[0] > bounds[1] { + panic("min should not be greater than max") + } else if bounds[0] < bounds[1] { + count = rand.Intn(bounds[1]-bounds[0]) + bounds[0] + } else { + count = bounds[0] } - return rule } + if len(bounds) > 2 { + panic("invalid number of parameter must be up to 2") + } + return count +} + +func (g *AlertRuleGenerator) GenerateMany(bounds ...int) []AlertRule { + count := g.getCount(bounds...) + result := make([]AlertRule, 0, count) + for i := 0; i < count; i++ { + result = append(result, g.Generate()) + } + return result +} + +func (g *AlertRuleGenerator) GenerateManyRef(bounds ...int) []*AlertRule { + count := g.getCount(bounds...) + + result := make([]*AlertRule, 0) + for i := 0; i < count; i++ { + r := g.Generate() + result = append(result, &r) + } + return result +} + +type AlertRuleMutators struct { } -func WithNotEmptyLabels(count int, prefix string) AlertRuleMutator { +func (a *AlertRuleMutators) WithNotEmptyLabels(count int, prefix string) AlertRuleMutator { return func(rule *AlertRule) { rule.Labels = GenerateAlertLabels(count, prefix) } } -func WithUniqueID() AlertRuleMutator { - usedID := make(map[int64]struct{}) +func (a *AlertRuleMutators) WithUniqueID() AlertRuleMutator { + ids := sync.Map{} return func(rule *AlertRule) { + id := rule.ID for { - id := rand.Int63n(1500) + 1 - if _, ok := usedID[id]; !ok { - usedID[id] = struct{}{} + _, exists := ids.LoadOrStore(id, struct{}{}) + if !exists { rule.ID = id return } + id = rand.Int63n(1500) + 1 } } } -func WithGroupIndex(groupIndex int) AlertRuleMutator { +func (a *AlertRuleMutators) WithGroupIndex(groupIndex int) AlertRuleMutator { return func(rule *AlertRule) { rule.RuleGroupIndex = groupIndex } } -func WithUniqueGroupIndex() AlertRuleMutator { - usedIdx := make(map[int]struct{}) +func (a *AlertRuleMutators) WithUniqueGroupIndex() AlertRuleMutator { + usedIdx := sync.Map{} return func(rule *AlertRule) { + idx := rule.RuleGroupIndex for { - idx := rand.Int() - if _, ok := usedIdx[idx]; !ok { - usedIdx[idx] = struct{}{} + if _, exists := usedIdx.LoadOrStore(idx, struct{}{}); !exists { rule.RuleGroupIndex = idx return } + idx = rand.Int() } } } -func WithSequentialGroupIndex() AlertRuleMutator { +func (a *AlertRuleMutators) WithSequentialGroupIndex() AlertRuleMutator { idx := 1 + m := sync.Mutex{} return func(rule *AlertRule) { + m.Lock() + defer m.Unlock() rule.RuleGroupIndex = idx idx++ } } -func WithOrgID(orgId int64) AlertRuleMutator { +func (a *AlertRuleMutators) WithOrgID(orgId int64) AlertRuleMutator { return func(rule *AlertRule) { rule.OrgID = orgId } } -func WithUniqueOrgID() AlertRuleMutator { - orgs := map[int64]struct{}{} +func (a *AlertRuleMutators) WithUniqueOrgID() AlertRuleMutator { + orgs := sync.Map{} return func(rule *AlertRule) { - var orgID int64 + orgID := rule.OrgID for { - orgID = rand.Int63() - if _, ok := orgs[orgID]; !ok { - break + if _, exist := orgs.LoadOrStore(orgID, struct{}{}); !exist { + rule.OrgID = orgID + return } + orgID = rand.Int63() } - orgs[orgID] = struct{}{} - rule.OrgID = orgID } } // WithNamespaceUIDNotIn generates a random namespace UID if it is among excluded -func WithNamespaceUIDNotIn(exclude ...string) AlertRuleMutator { +func (a *AlertRuleMutators) WithNamespaceUIDNotIn(exclude ...string) AlertRuleMutator { return func(rule *AlertRule) { for { if !slices.Contains(exclude, rule.NamespaceUID) { return } - rule.NamespaceUID = uuid.NewString() + rule.NamespaceUID = util.GenerateShortUID() } } } -func WithNamespace(namespace *folder.Folder) AlertRuleMutator { +func (a *AlertRuleMutators) WithNamespaceUID(namespaceUID string) AlertRuleMutator { return func(rule *AlertRule) { - rule.NamespaceUID = namespace.UID + rule.NamespaceUID = namespaceUID } } -func WithInterval(interval time.Duration) AlertRuleMutator { +func (a *AlertRuleMutators) WithNamespace(namespace *folder.Folder) AlertRuleMutator { + return a.WithNamespaceUID(namespace.UID) +} + +func (a *AlertRuleMutators) WithInterval(interval time.Duration) AlertRuleMutator { return func(rule *AlertRule) { rule.IntervalSeconds = int64(interval.Seconds()) } } -func WithIntervalBetween(min, max int64) AlertRuleMutator { +func (a *AlertRuleMutators) WithIntervalSeconds(seconds int64) AlertRuleMutator { + return func(rule *AlertRule) { + rule.IntervalSeconds = seconds + } +} + +// WithIntervalMatching mutator that generates random interval and `for` duration that are times of the provided base interval. +func (a *AlertRuleMutators) WithIntervalMatching(baseInterval time.Duration) AlertRuleMutator { + return func(rule *AlertRule) { + rule.IntervalSeconds = int64(baseInterval.Seconds()) * (rand.Int63n(10) + 1) + rule.For = time.Duration(rule.IntervalSeconds*rand.Int63n(9)+1) * time.Second + } +} + +func (a *AlertRuleMutators) WithIntervalBetween(min, max int64) AlertRuleMutator { return func(rule *AlertRule) { rule.IntervalSeconds = rand.Int63n(max-min) + min } } -func WithTitle(title string) AlertRuleMutator { +func (a *AlertRuleMutators) WithTitle(title string) AlertRuleMutator { return func(rule *AlertRule) { rule.Title = title } } -func WithFor(duration time.Duration) AlertRuleMutator { +func (a *AlertRuleMutators) WithFor(duration time.Duration) AlertRuleMutator { return func(rule *AlertRule) { rule.For = duration } } -func WithForNTimes(timesOfInterval int64) AlertRuleMutator { +func (a *AlertRuleMutators) WithForNTimes(timesOfInterval int64) AlertRuleMutator { return func(rule *AlertRule) { rule.For = time.Duration(rule.IntervalSeconds*timesOfInterval) * time.Second } } -func WithNoDataExecAs(nodata NoDataState) AlertRuleMutator { +func (a *AlertRuleMutators) WithNoDataExecAs(nodata NoDataState) AlertRuleMutator { return func(rule *AlertRule) { rule.NoDataState = nodata } } -func WithErrorExecAs(err ExecutionErrorState) AlertRuleMutator { +func (a *AlertRuleMutators) WithErrorExecAs(err ExecutionErrorState) AlertRuleMutator { return func(rule *AlertRule) { rule.ExecErrState = err } } -func WithAnnotations(a data.Labels) AlertRuleMutator { +func (a *AlertRuleMutators) WithAnnotations(lbls data.Labels) AlertRuleMutator { return func(rule *AlertRule) { - rule.Annotations = a + rule.Annotations = lbls } } -func WithAnnotation(key, value string) AlertRuleMutator { +func (a *AlertRuleMutators) WithAnnotation(key, value string) AlertRuleMutator { return func(rule *AlertRule) { if rule.Annotations == nil { rule.Annotations = data.Labels{} @@ -244,13 +333,13 @@ func WithAnnotation(key, value string) AlertRuleMutator { } } -func WithLabels(a data.Labels) AlertRuleMutator { +func (a *AlertRuleMutators) WithLabels(lbls data.Labels) AlertRuleMutator { return func(rule *AlertRule) { - rule.Labels = a + rule.Labels = lbls } } -func WithLabel(key, value string) AlertRuleMutator { +func (a *AlertRuleMutators) WithLabel(key, value string) AlertRuleMutator { return func(rule *AlertRule) { if rule.Labels == nil { rule.Labels = data.Labels{} @@ -259,35 +348,80 @@ func WithLabel(key, value string) AlertRuleMutator { } } -func WithUniqueUID(knownUids *sync.Map) AlertRuleMutator { +func (a *AlertRuleMutators) WithDashboardAndPanel(dashboardUID *string, panelID *int64) AlertRuleMutator { + return func(rule *AlertRule) { + rule.DashboardUID = dashboardUID + rule.PanelID = panelID + } +} + +// WithUniqueUID returns AlertRuleMutator that generates a random UID if it is among UIDs known by the instance of mutator. +// NOTE: two instances of the mutator do not share known UID. +// Example #1 reuse mutator instance: +// +// mut := WithUniqueUID() +// rule1 := RuleGen.With(mut).Generate() +// rule2 := RuleGen.With(mut).Generate() +// +// Example #2 reuse generator: +// +// gen := RuleGen.With(WithUniqueUID()) +// rule1 := gen.Generate() +// rule2 := gen.Generate() +// +// Example #3 non-unique: +// +// rule1 := RuleGen.With(WithUniqueUID()).Generate +// rule2 := RuleGen.With(WithUniqueUID()).Generate +func (a *AlertRuleMutators) WithUniqueUID() AlertRuleMutator { + uids := sync.Map{} return func(rule *AlertRule) { uid := rule.UID for { - _, ok := knownUids.LoadOrStore(uid, struct{}{}) - if !ok { + _, exist := uids.LoadOrStore(uid, struct{}{}) + if !exist { rule.UID = uid return } - uid = uuid.NewString() + uid = util.GenerateShortUID() } } } -func WithUniqueTitle(knownTitles *sync.Map) AlertRuleMutator { +// WithUniqueTitle returns AlertRuleMutator that generates a random title if the rule's title is among titles known by the instance of mutator. +// Two instances of the mutator do not share known titles. +// Example #1 reuse mutator instance: +// +// mut := WithUniqueTitle() +// rule1 := RuleGen.With(mut).Generate() +// rule2 := RuleGen.With(mut).Generate() +// +// Example #2 reuse generator: +// +// gen := RuleGen.With(WithUniqueTitle()) +// rule1 := gen.Generate() +// rule2 := gen.Generate() +// +// Example #3 non-unique: +// +// rule1 := RuleGen.With(WithUniqueTitle()).Generate +// rule2 := RuleGen.With(WithUniqueTitle()).Generate +func (a *AlertRuleMutators) WithUniqueTitle() AlertRuleMutator { + titles := sync.Map{} return func(rule *AlertRule) { title := rule.Title for { - _, ok := knownTitles.LoadOrStore(title, struct{}{}) - if !ok { + _, exist := titles.LoadOrStore(title, struct{}{}) + if !exist { rule.Title = title return } - title = uuid.NewString() + title = fmt.Sprintf("title-%s", util.GenerateShortUID()) } } } -func WithQuery(query ...AlertQuery) AlertRuleMutator { +func (a *AlertRuleMutators) WithQuery(query ...AlertQuery) AlertRuleMutator { return func(rule *AlertRule) { rule.Data = query if len(query) > 1 { @@ -296,7 +430,19 @@ func WithQuery(query ...AlertQuery) AlertRuleMutator { } } -func WithGroupKey(groupKey AlertRuleGroupKey) AlertRuleMutator { +func (a *AlertRuleMutators) WithGroupName(groupName string) AlertRuleMutator { + return func(rule *AlertRule) { + rule.RuleGroup = groupName + } +} + +func (a *AlertRuleMutators) WithGroupPrefix(prefix string) AlertRuleMutator { + return func(rule *AlertRule) { + rule.RuleGroup = fmt.Sprintf("%s%s", prefix, util.GenerateShortUID()) + } +} + +func (a *AlertRuleMutators) WithGroupKey(groupKey AlertRuleGroupKey) AlertRuleMutator { return func(rule *AlertRule) { rule.RuleGroup = groupKey.RuleGroup rule.OrgID = groupKey.OrgID @@ -304,19 +450,48 @@ func WithGroupKey(groupKey AlertRuleGroupKey) AlertRuleMutator { } } -func WithNotificationSettingsGen(ns func() NotificationSettings) AlertRuleMutator { +// WithSameGroup generates a random group name and assigns it to all rules passed to it +func (a *AlertRuleMutators) WithSameGroup() AlertRuleMutator { + once := sync.Once{} + name := "" + return func(rule *AlertRule) { + once.Do(func() { + name = util.GenerateShortUID() + }) + rule.RuleGroup = name + } +} + +func (a *AlertRuleMutators) WithNotificationSettingsGen(ns func() NotificationSettings) AlertRuleMutator { return func(rule *AlertRule) { rule.NotificationSettings = []NotificationSettings{ns()} } } +func (a *AlertRuleMutators) WithNotificationSettings(ns NotificationSettings) AlertRuleMutator { + return func(rule *AlertRule) { + rule.NotificationSettings = []NotificationSettings{ns} + } +} -func WithNoNotificationSettings() AlertRuleMutator { +func (a *AlertRuleMutators) WithNoNotificationSettings() AlertRuleMutator { return func(rule *AlertRule) { rule.NotificationSettings = nil } } -func GenerateAlertLabels(count int, prefix string) data.Labels { +func (a *AlertRuleMutators) WithIsPaused(paused bool) AlertRuleMutator { + return func(rule *AlertRule) { + rule.IsPaused = paused + } +} + +func (g *AlertRuleGenerator) GenerateLabels(min, max int, prefix string) data.Labels { + count := max + if min > max { + panic("min should not be greater than max") + } else if min < max { + count = rand.Intn(max-min) + min + } labels := make(data.Labels, count) for i := 0; i < count; i++ { labels[prefix+"key-"+util.GenerateShortUID()] = prefix + "value-" + util.GenerateShortUID() @@ -324,7 +499,15 @@ func GenerateAlertLabels(count int, prefix string) data.Labels { return labels } +func GenerateAlertLabels(count int, prefix string) data.Labels { + return RuleGen.GenerateLabels(count, count, prefix) +} + func GenerateAlertQuery() AlertQuery { + return RuleGen.GenerateQuery() +} + +func (g *AlertRuleGenerator) GenerateQuery() AlertQuery { f := rand.Intn(10) + 5 t := rand.Intn(f) @@ -343,35 +526,10 @@ func GenerateAlertQuery() AlertQuery { } } -// GenerateUniqueAlertRules generates many random alert rules and makes sure that they have unique UID. -// It returns a tuple where first element is a map where keys are UID of alert rule and the second element is a slice of the same rules -func GenerateUniqueAlertRules(count int, f func() *AlertRule) (map[string]*AlertRule, []*AlertRule) { - uIDs := make(map[string]*AlertRule, count) - result := make([]*AlertRule, 0, count) - for len(result) < count { - rule := f() - if _, ok := uIDs[rule.UID]; ok { - continue - } - result = append(result, rule) - uIDs[rule.UID] = rule - } - return uIDs, result -} - -// GenerateAlertRulesSmallNonEmpty generates 1 to 5 rules using the provided generator -func GenerateAlertRulesSmallNonEmpty(f func() *AlertRule) []*AlertRule { - return GenerateAlertRules(rand.Intn(4)+1, f) -} - -// GenerateAlertRules generates many random alert rules. Does not guarantee that rules are unique (by UID) -func GenerateAlertRules(count int, f func() *AlertRule) []*AlertRule { - result := make([]*AlertRule, 0, count) - for len(result) < count { - rule := f() - result = append(result, rule) +func (g *AlertRuleGenerator) WithCondition(condition string) AlertRuleMutator { + return func(r *AlertRule) { + r.Condition = condition } - return result } // GenerateRuleKey generates a random alert rule key @@ -392,7 +550,7 @@ func GenerateGroupKey(orgID int64) AlertRuleGroupKey { } // CopyRule creates a deep copy of AlertRule -func CopyRule(r *AlertRule) *AlertRule { +func CopyRule(r *AlertRule, mutators ...AlertRuleMutator) *AlertRule { result := AlertRule{ ID: r.ID, OrgID: r.OrgID, @@ -449,6 +607,12 @@ func CopyRule(r *AlertRule) *AlertRule { result.NotificationSettings = append(result.NotificationSettings, CopyNotificationSettings(s)) } + if len(mutators) > 0 { + for _, mutator := range mutators { + mutator(&result) + } + } + return &result } @@ -687,10 +851,6 @@ func NotificationSettingsGen(mutators ...Mutator[NotificationSettings]) func() N } } -var ( - NSMuts = NotificationSettingsMutators{} -) - type NotificationSettingsMutators struct{} func (n NotificationSettingsMutators) WithReceiver(receiver string) Mutator[NotificationSettings] { diff --git a/pkg/services/ngalert/ngalert_test.go b/pkg/services/ngalert/ngalert_test.go index b717f562cb17f..ed7bee52d7cad 100644 --- a/pkg/services/ngalert/ngalert_test.go +++ b/pkg/services/ngalert/ngalert_test.go @@ -29,7 +29,8 @@ func Test_subscribeToFolderChanges(t *testing.T) { UID: util.GenerateShortUID(), Title: "Folder" + util.GenerateShortUID(), } - rules := models.GenerateAlertRules(5, models.AlertRuleGen(models.WithOrgID(orgID), models.WithNamespace(folder))) + gen := models.RuleGen + rules := gen.With(gen.WithOrgID(orgID), gen.WithNamespace(folder)).GenerateManyRef(5) bus := bus.ProvideBus(tracing.InitializeTracerForTest()) db := fakes.NewRuleStore(t) diff --git a/pkg/services/ngalert/provisioning/accesscontrol_test.go b/pkg/services/ngalert/provisioning/accesscontrol_test.go index 04d9000c0ecea..a55e44429b2e1 100644 --- a/pkg/services/ngalert/provisioning/accesscontrol_test.go +++ b/pkg/services/ngalert/provisioning/accesscontrol_test.go @@ -84,7 +84,7 @@ func TestCanWriteAllRules(t *testing.T) { func TestAuthorizeAccessToRuleGroup(t *testing.T) { testUser := &user.SignedInUser{} - rules := models.GenerateAlertRules(1, models.AlertRuleGen()) + rules := models.RuleGen.GenerateManyRef(1) t.Run("should return nil when user has provisioning permissions", func(t *testing.T) { rs := &fakes.FakeRuleService{} diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index fea81b1905315..d035f5b78bf7a 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -551,7 +551,8 @@ func TestCreateAlertRule(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(groupIntervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(groupIntervalSeconds)).GenerateManyRef(3) groupProvenance := models.ProvenanceAPI initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { @@ -567,14 +568,14 @@ func TestCreateAlertRule(t *testing.T) { t.Run("when user can write all rules", func(t *testing.T) { t.Run("and a new rule creates a new group", func(t *testing.T) { - rule := models.AlertRuleGen(models.WithOrgID(orgID))() + rule := gen.With(gen.WithOrgID(orgID)).Generate() service, ruleStore, provenanceStore, ac := initServiceWithData(t) ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil } - actualRule, err := service.CreateAlertRule(context.Background(), u, *rule, models.ProvenanceFile) + actualRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceFile) require.NoError(t, err) require.Len(t, ac.Calls, 1) @@ -601,14 +602,14 @@ func TestCreateAlertRule(t *testing.T) { }) }) t.Run("and it adds a rule to a group", func(t *testing.T) { - rule := models.AlertRuleGen(models.WithGroupKey(groupKey))() + rule := gen.With(gen.WithGroupKey(groupKey)).Generate() service, ruleStore, provenanceStore, ac := initServiceWithData(t) ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil } - actualRule, err := service.CreateAlertRule(context.Background(), u, *rule, models.ProvenanceNone) + actualRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) require.Len(t, ac.Calls, 1) @@ -637,7 +638,7 @@ func TestCreateAlertRule(t *testing.T) { }) t.Run("when user cannot write all rules", func(t *testing.T) { t.Run("and it creates a new group", func(t *testing.T) { - rule := models.AlertRuleGen(models.WithOrgID(orgID))() + rule := gen.With(gen.WithOrgID(orgID)).Generate() t.Run("it should authorize the change", func(t *testing.T) { service, ruleStore, provenanceStore, ac := initServiceWithData(t) @@ -654,7 +655,7 @@ func TestCreateAlertRule(t *testing.T) { return nil } - actualRule, err := service.CreateAlertRule(context.Background(), u, *rule, models.ProvenanceFile) + actualRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceFile) require.NoError(t, err) require.Len(t, ac.Calls, 2) @@ -683,7 +684,7 @@ func TestCreateAlertRule(t *testing.T) { }) }) t.Run("and it adds a rule to a group", func(t *testing.T) { - rule := models.AlertRuleGen(models.WithGroupKey(groupKey))() + rule := gen.With(gen.WithGroupKey(groupKey)).Generate() t.Run("it should authorize the change to whole group", func(t *testing.T) { service, ruleStore, provenanceStore, ac := initServiceWithData(t) @@ -701,7 +702,7 @@ func TestCreateAlertRule(t *testing.T) { return nil } - actualRule, err := service.CreateAlertRule(context.Background(), u, *rule, models.ProvenanceNone) + actualRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone) require.NoError(t, err) require.Len(t, ac.Calls, 2) @@ -730,7 +731,7 @@ func TestCreateAlertRule(t *testing.T) { }) }) t.Run("it should not insert if not authorized", func(t *testing.T) { - rule := models.AlertRuleGen(models.WithGroupKey(groupKey))() + rule := gen.With(gen.WithGroupKey(groupKey)).Generate() service, ruleStore, _, ac := initServiceWithData(t) ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { @@ -741,7 +742,7 @@ func TestCreateAlertRule(t *testing.T) { return expectedErr } - _, err := service.CreateAlertRule(context.Background(), u, *rule, models.ProvenanceFile) + _, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceFile) require.ErrorIs(t, expectedErr, err) require.Len(t, ac.Calls, 2) @@ -797,7 +798,8 @@ func TestUpdateAlertRule(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(groupIntervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(groupIntervalSeconds)).GenerateManyRef(3) groupProvenance := models.ProvenanceAPI initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { @@ -899,7 +901,8 @@ func TestDeleteAlertRule(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(groupIntervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(groupIntervalSeconds)).GenerateManyRef(3) groupProvenance := models.ProvenanceAPI initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { @@ -990,7 +993,8 @@ func TestGetAlertRule(t *testing.T) { orgID := rand.Int63() u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(3) rule := rules[0] expectedProvenance := models.ProvenanceAPI @@ -1118,7 +1122,8 @@ func TestGetRuleGroup(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) intervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(intervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(intervalSeconds)).GenerateManyRef(3) derefRules := make([]models.AlertRule, 0, len(rules)) for _, rule := range rules { derefRules = append(derefRules, *rule) @@ -1226,9 +1231,10 @@ func TestGetAlertRules(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey1 := models.GenerateGroupKey(orgID) groupKey2 := models.GenerateGroupKey(orgID) - rules1 := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey1))) + gen := models.RuleGen + rules1 := gen.With(gen.WithGroupKey(groupKey1), gen.WithUniqueGroupIndex()).GenerateManyRef(3) models.RulesGroup(rules1).SortByGroupIndex() - rules2 := models.GenerateAlertRules(4, models.AlertRuleGen(models.WithGroupKey(groupKey2))) + rules2 := gen.With(gen.WithGroupKey(groupKey2), gen.WithUniqueGroupIndex()).GenerateManyRef(4) models.RulesGroup(rules2).SortByGroupIndex() allRules := append(rules1, rules2...) expectedProvenance := models.ProvenanceAPI @@ -1325,7 +1331,8 @@ func TestReplaceGroup(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(groupIntervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(groupIntervalSeconds)).GenerateManyRef(3) groupProvenance := models.ProvenanceAPI initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { @@ -1438,7 +1445,8 @@ func TestDeleteRuleGroup(t *testing.T) { u := &user.SignedInUser{OrgID: orgID} groupKey := models.GenerateGroupKey(orgID) groupIntervalSeconds := int64(30) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey), models.WithInterval(time.Duration(groupIntervalSeconds)*time.Second))) + gen := models.RuleGen + rules := gen.With(gen.WithGroupKey(groupKey), gen.WithIntervalSeconds(groupIntervalSeconds)).GenerateManyRef(3) groupProvenance := models.ProvenanceAPI initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { diff --git a/pkg/services/ngalert/schedule/alert_rule_test.go b/pkg/services/ngalert/schedule/alert_rule_test.go index f910376cfba78..3eb82f141e195 100644 --- a/pkg/services/ngalert/schedule/alert_rule_test.go +++ b/pkg/services/ngalert/schedule/alert_rule_test.go @@ -13,20 +13,22 @@ import ( alertingModels "github.com/grafana/alerting/models" "github.com/grafana/grafana-plugin-sdk-go/data" - definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - "github.com/grafana/grafana/pkg/services/ngalert/eval" - models "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/state" - "github.com/grafana/grafana/pkg/util" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" prometheusModel "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" mock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + definitions "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/eval" + models "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/util" ) func TestAlertRule(t *testing.T) { + gen := models.RuleGen type evalResponse struct { success bool droppedEval *Evaluation @@ -78,7 +80,7 @@ func TestAlertRule(t *testing.T) { resultCh := make(chan evalResponse) data := &Evaluation{ scheduledAt: expected, - rule: models.AlertRuleGen()(), + rule: gen.GenerateRef(), folderTitle: util.GenerateShortUID(), } go func() { @@ -103,7 +105,7 @@ func TestAlertRule(t *testing.T) { resultCh2 := make(chan evalResponse) data := &Evaluation{ scheduledAt: time1, - rule: models.AlertRuleGen()(), + rule: gen.GenerateRef(), folderTitle: util.GenerateShortUID(), } data2 := &Evaluation{ @@ -146,7 +148,7 @@ func TestAlertRule(t *testing.T) { resultCh := make(chan evalResponse) data := &Evaluation{ scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), + rule: gen.GenerateRef(), folderTitle: util.GenerateShortUID(), } go func() { @@ -176,7 +178,7 @@ func TestAlertRule(t *testing.T) { r.Stop(nil) data := &Evaluation{ scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), + rule: gen.GenerateRef(), folderTitle: util.GenerateShortUID(), } success, dropped := r.Eval(data) @@ -225,7 +227,7 @@ func TestAlertRule(t *testing.T) { case 2: r.Eval(&Evaluation{ scheduledAt: time.Now(), - rule: models.AlertRuleGen()(), + rule: gen.GenerateRef(), folderTitle: util.GenerateShortUID(), }) case 3: @@ -245,6 +247,7 @@ func blankRuleForTests(ctx context.Context) *alertRule { } func TestRuleRoutine(t *testing.T) { + gen := models.RuleGen createSchedule := func( evalAppliedChan chan time.Time, senderMock *SyncAlertsSenderMock, @@ -270,7 +273,7 @@ func TestRuleRoutine(t *testing.T) { evalAppliedChan := make(chan time.Time) sch, ruleStore, instanceStore, reg := createSchedule(evalAppliedChan, nil) - rule := models.AlertRuleGen(withQueryForState(t, evalState))() + rule := gen.With(withQueryForState(t, evalState)).GenerateRef() ruleStore.PutRule(context.Background(), rule) folderTitle := ruleStore.getNamespaceTitle(rule.NamespaceUID) factory := ruleFactoryFromScheduler(sch) @@ -432,7 +435,7 @@ func TestRuleRoutine(t *testing.T) { stoppedChan := make(chan error) sch, _, _, _ := createSchedule(make(chan time.Time), nil) - rule := models.AlertRuleGen()() + rule := gen.GenerateRef() _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) expectedStates := sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID) require.NotEmpty(t, expectedStates) @@ -454,7 +457,7 @@ func TestRuleRoutine(t *testing.T) { stoppedChan := make(chan error) sch, _, _, _ := createSchedule(make(chan time.Time), nil) - rule := models.AlertRuleGen()() + rule := gen.GenerateRef() _ = sch.stateManager.ProcessEvalResults(context.Background(), sch.clock.Now(), rule, eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen(eval.WithEvaluatedAt(sch.clock.Now()))), nil) require.NotEmpty(t, sch.stateManager.GetStatesForRuleUID(rule.OrgID, rule.UID)) @@ -474,7 +477,7 @@ func TestRuleRoutine(t *testing.T) { }) t.Run("when a message is sent to update channel", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() + rule := gen.With(withQueryForState(t, eval.Normal)).GenerateRef() folderTitle := "folderName" ruleFp := ruleWithFolder{rule, folderTitle}.Fingerprint() @@ -557,7 +560,7 @@ func TestRuleRoutine(t *testing.T) { }) t.Run("when evaluation fails", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Error))() + rule := gen.With(withQueryForState(t, eval.Error)).GenerateRef() rule.ExecErrState = models.ErrorErrState evalAppliedChan := make(chan time.Time) @@ -678,7 +681,7 @@ func TestRuleRoutine(t *testing.T) { t.Run("when there are alerts that should be firing", func(t *testing.T) { t.Run("it should call sender", func(t *testing.T) { // eval.Alerting makes state manager to create notifications for alertmanagers - rule := models.AlertRuleGen(withQueryForState(t, eval.Alerting))() + rule := gen.With(withQueryForState(t, eval.Alerting)).GenerateRef() evalAppliedChan := make(chan time.Time) @@ -712,7 +715,7 @@ func TestRuleRoutine(t *testing.T) { }) t.Run("when there are no alerts to send it should not call notifiers", func(t *testing.T) { - rule := models.AlertRuleGen(withQueryForState(t, eval.Normal))() + rule := gen.With(withQueryForState(t, eval.Normal)).GenerateRef() evalAppliedChan := make(chan time.Time) diff --git a/pkg/services/ngalert/schedule/jitter_test.go b/pkg/services/ngalert/schedule/jitter_test.go index ae7ead86fe798..9ffc358636cc8 100644 --- a/pkg/services/ngalert/schedule/jitter_test.go +++ b/pkg/services/ngalert/schedule/jitter_test.go @@ -4,14 +4,17 @@ import ( "testing" "time" - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/stretchr/testify/require" + + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ) func TestJitter(t *testing.T) { + gen := ngmodels.RuleGen + genWithInterval10to600 := gen.With(gen.WithIntervalBetween(10, 600)) t.Run("when strategy is JitterNever", func(t *testing.T) { t.Run("offset is always zero", func(t *testing.T) { - rules := createTestRules(100, ngmodels.WithIntervalBetween(10, 600)) + rules := genWithInterval10to600.GenerateManyRef(100) baseInterval := 10 * time.Second for _, r := range rules { @@ -23,7 +26,7 @@ func TestJitter(t *testing.T) { t.Run("when strategy is JitterByGroup", func(t *testing.T) { t.Run("offset is stable for the same rule", func(t *testing.T) { - rule := ngmodels.AlertRuleGen(ngmodels.WithIntervalBetween(10, 600))() + rule := genWithInterval10to600.GenerateRef() baseInterval := 10 * time.Second original := jitterOffsetInTicks(rule, baseInterval, JitterByGroup) @@ -35,7 +38,7 @@ func TestJitter(t *testing.T) { t.Run("offset is on the interval [0, interval/baseInterval)", func(t *testing.T) { baseInterval := 10 * time.Second - rules := createTestRules(1000, ngmodels.WithIntervalBetween(10, 600)) + rules := genWithInterval10to600.GenerateManyRef(1000) for _, r := range rules { offset := jitterOffsetInTicks(r, baseInterval, JitterByGroup) @@ -49,8 +52,8 @@ func TestJitter(t *testing.T) { baseInterval := 10 * time.Second group1 := ngmodels.AlertRuleGroupKey{} group2 := ngmodels.AlertRuleGroupKey{} - rules1 := createTestRules(1000, ngmodels.WithInterval(60*time.Second), ngmodels.WithGroupKey(group1)) - rules2 := createTestRules(1000, ngmodels.WithInterval(1*time.Hour), ngmodels.WithGroupKey(group2)) + rules1 := gen.With(gen.WithInterval(60*time.Second), gen.WithGroupKey(group1)).GenerateManyRef(1000) + rules2 := gen.With(gen.WithInterval(1*time.Hour), gen.WithGroupKey(group2)).GenerateManyRef(1000) group1Offset := jitterOffsetInTicks(rules1[0], baseInterval, JitterByGroup) for _, r := range rules1 { @@ -67,7 +70,7 @@ func TestJitter(t *testing.T) { t.Run("when strategy is JitterByRule", func(t *testing.T) { t.Run("offset is stable for the same rule", func(t *testing.T) { - rule := ngmodels.AlertRuleGen(ngmodels.WithIntervalBetween(10, 600))() + rule := genWithInterval10to600.GenerateRef() baseInterval := 10 * time.Second original := jitterOffsetInTicks(rule, baseInterval, JitterByRule) @@ -79,7 +82,7 @@ func TestJitter(t *testing.T) { t.Run("offset is on the interval [0, interval/baseInterval)", func(t *testing.T) { baseInterval := 10 * time.Second - rules := createTestRules(1000, ngmodels.WithIntervalBetween(10, 600)) + rules := genWithInterval10to600.GenerateManyRef(1000) for _, r := range rules { offset := jitterOffsetInTicks(r, baseInterval, JitterByRule) @@ -90,11 +93,3 @@ func TestJitter(t *testing.T) { }) }) } - -func createTestRules(n int, mutators ...ngmodels.AlertRuleMutator) []*ngmodels.AlertRule { - result := make([]*ngmodels.AlertRule, 0, n) - for i := 0; i < n; i++ { - result = append(result, ngmodels.AlertRuleGen(mutators...)()) - } - return result -} diff --git a/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go b/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go index 14648031c8a4f..0ecbeb4f008b3 100644 --- a/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go +++ b/pkg/services/ngalert/schedule/loaded_metrics_reader_test.go @@ -13,7 +13,7 @@ import ( ) func TestLoadedResultsFromRuleState(t *testing.T) { - rule := ngmodels.AlertRuleGen()() + rule := ngmodels.RuleGen.GenerateRef() p := &FakeRuleStateProvider{ map[ngmodels.AlertRuleKey][]*state.State{ rule.GetKey(): { diff --git a/pkg/services/ngalert/schedule/registry_bench_test.go b/pkg/services/ngalert/schedule/registry_bench_test.go index 7ab26df885cd9..2b03613370284 100644 --- a/pkg/services/ngalert/schedule/registry_bench_test.go +++ b/pkg/services/ngalert/schedule/registry_bench_test.go @@ -12,12 +12,13 @@ import ( ) func BenchmarkRuleWithFolderFingerprint(b *testing.B) { - rules := models.GenerateAlertRules(b.N, models.AlertRuleGen(func(rule *models.AlertRule) { + gen := models.RuleGen + rules := gen.With(func(rule *models.AlertRule) { rule.Data = make([]models.AlertQuery, 0, 5) for i := 0; i < rand.Intn(5)+1; i++ { - rule.Data = append(rule.Data, models.GenerateAlertQuery()) + rule.Data = append(rule.Data, gen.GenerateQuery()) } - })) + }).GenerateManyRef(b.N) folder := uuid.NewString() b.ReportAllocs() b.ResetTimer() diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index c617d91a56230..d7628cdd3568d 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -78,7 +78,8 @@ func TestSchedulableAlertRulesRegistry(t *testing.T) { } func TestSchedulableAlertRulesRegistry_set(t *testing.T) { - _, initialRules := models.GenerateUniqueAlertRules(100, models.AlertRuleGen()) + gen := models.RuleGen + initialRules := gen.GenerateManyRef(100) init := make(map[models.AlertRuleKey]*models.AlertRule, len(initialRules)) for _, rule := range initialRules { init[rule.GetKey()] = rule @@ -95,7 +96,7 @@ func TestSchedulableAlertRulesRegistry_set(t *testing.T) { t.Run("should return empty diff if version does not change", func(t *testing.T) { newRules := make([]*models.AlertRule, 0, len(initialRules)) // generate random and then override rule key + version - _, randomNew := models.GenerateUniqueAlertRules(len(initialRules), models.AlertRuleGen()) + randomNew := gen.GenerateManyRef(len(initialRules)) for i := 0; i < len(initialRules); i++ { rule := randomNew[i] oldRule := initialRules[i] @@ -128,7 +129,7 @@ func TestSchedulableAlertRulesRegistry_set(t *testing.T) { } func TestRuleWithFolderFingerprint(t *testing.T) { - rule := models.AlertRuleGen()() + rule := models.RuleGen.GenerateRef() title := uuid.NewString() f := ruleWithFolder{rule: rule, folderTitle: title}.Fingerprint() t.Run("should calculate a fingerprint", func(t *testing.T) { diff --git a/pkg/services/ngalert/schedule/schedule_unit_test.go b/pkg/services/ngalert/schedule/schedule_unit_test.go index 4808f8312b8a5..50258cb2247eb 100644 --- a/pkg/services/ngalert/schedule/schedule_unit_test.go +++ b/pkg/services/ngalert/schedule/schedule_unit_test.go @@ -101,9 +101,9 @@ func TestProcessTicks(t *testing.T) { } tick := time.Time{} - + gen := models.RuleGen // create alert rule under main org with one second interval - alertRule1 := models.AlertRuleGen(models.WithOrgID(mainOrgID), models.WithInterval(cfg.BaseInterval), models.WithTitle("rule-1"))() + alertRule1 := gen.With(gen.WithOrgID(mainOrgID), gen.WithInterval(cfg.BaseInterval), gen.WithTitle("rule-1")).GenerateRef() ruleStore.PutRule(ctx, alertRule1) t.Run("on 1st tick alert rule should be evaluated", func(t *testing.T) { @@ -132,7 +132,7 @@ func TestProcessTicks(t *testing.T) { }) // add alert rule under main org with three base intervals - alertRule2 := models.AlertRuleGen(models.WithOrgID(mainOrgID), models.WithInterval(3*cfg.BaseInterval), models.WithTitle("rule-2"))() + alertRule2 := gen.With(gen.WithOrgID(mainOrgID), gen.WithInterval(3*cfg.BaseInterval), gen.WithTitle("rule-2")).GenerateRef() ruleStore.PutRule(ctx, alertRule2) t.Run("on 2nd tick first alert rule should be evaluated", func(t *testing.T) { @@ -317,7 +317,7 @@ func TestProcessTicks(t *testing.T) { }) // create alert rule with one base interval - alertRule3 := models.AlertRuleGen(models.WithOrgID(mainOrgID), models.WithInterval(cfg.BaseInterval), models.WithTitle("rule-3"))() + alertRule3 := gen.With(gen.WithOrgID(mainOrgID), gen.WithInterval(cfg.BaseInterval), gen.WithTitle("rule-3")).GenerateRef() ruleStore.PutRule(ctx, alertRule3) t.Run("on 10th tick a new alert rule should be evaluated", func(t *testing.T) { @@ -361,7 +361,7 @@ func TestSchedule_deleteAlertRule(t *testing.T) { t.Run("it should stop evaluation loop and remove the controller from registry", func(t *testing.T) { sch := setupScheduler(t, nil, nil, nil, nil, nil) ruleFactory := ruleFactoryFromScheduler(sch) - rule := models.AlertRuleGen()() + rule := models.RuleGen.GenerateRef() key := rule.GetKey() info, _ := sch.registry.getOrCreate(context.Background(), key, ruleFactory) sch.deleteAlertRule(key) diff --git a/pkg/services/ngalert/state/cache_bench_test.go b/pkg/services/ngalert/state/cache_bench_test.go index 1373d1ae71389..d8b0cdb04faff 100644 --- a/pkg/services/ngalert/state/cache_bench_test.go +++ b/pkg/services/ngalert/state/cache_bench_test.go @@ -16,7 +16,7 @@ import ( func BenchmarkGetOrCreateTest(b *testing.B) { cache := newCache() - rule := models.AlertRuleGen(func(rule *models.AlertRule) { + rule := models.RuleGen.With(func(rule *models.AlertRule) { for i := 0; i < 2; i++ { rule.Labels = data.Labels{ "label-1": "{{ $value }}", @@ -27,7 +27,7 @@ func BenchmarkGetOrCreateTest(b *testing.B) { "anno-2": "{{ $values.A.Labels.instance }} has value {{ $values.A }}", } } - })() + }).GenerateRef() result := eval.ResultGen(func(r *eval.Result) { r.Values = map[string]eval.NumberValueCapture{ "A": { diff --git a/pkg/services/ngalert/state/cache_test.go b/pkg/services/ngalert/state/cache_test.go index 58a0adbfafdf1..2329271422661 100644 --- a/pkg/services/ngalert/state/cache_test.go +++ b/pkg/services/ngalert/state/cache_test.go @@ -126,7 +126,8 @@ func Test_getOrCreate(t *testing.T) { l := log.New("test") c := newCache() - generateRule := models.AlertRuleGen(models.WithNotEmptyLabels(5, "rule-")) + gen := models.RuleGen + generateRule := gen.With(gen.WithNotEmptyLabels(5, "rule-")).GenerateRef t.Run("should combine all labels", func(t *testing.T) { rule := generateRule() diff --git a/pkg/services/ngalert/state/historian/annotation_test.go b/pkg/services/ngalert/state/historian/annotation_test.go index 0192d3216590b..da09fdc333b66 100644 --- a/pkg/services/ngalert/state/historian/annotation_test.go +++ b/pkg/services/ngalert/state/historian/annotation_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/annotations" @@ -128,7 +129,7 @@ func createTestAnnotationSutWithStore(t *testing.T, annotations AnnotationStore) met := metrics.NewHistorianMetrics(prometheus.NewRegistry(), metrics.Subsystem) rules := fakes.NewRuleStore(t) rules.Rules[1] = []*models.AlertRule{ - models.AlertRuleGen(withOrgID(1), withUID("my-rule"))(), + models.RuleGen.With(models.RuleMuts.WithOrgID(1), withUID("my-rule")).GenerateRef(), } return NewAnnotationBackend(annotations, rules, met) } @@ -138,7 +139,7 @@ func createTestAnnotationBackendSutWithMetrics(t *testing.T, met *metrics.Histor fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo() rules := fakes.NewRuleStore(t) rules.Rules[1] = []*models.AlertRule{ - models.AlertRuleGen(withOrgID(1), withUID("my-rule"))(), + models.RuleGen.With(models.RuleMuts.WithOrgID(1), withUID("my-rule")).GenerateRef(), } dbs := &dashboards.FakeDashboardService{} dbs.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) @@ -150,7 +151,7 @@ func createFailingAnnotationSut(t *testing.T, met *metrics.Historian) *Annotatio fakeAnnoRepo := &failingAnnotationRepo{} rules := fakes.NewRuleStore(t) rules.Rules[1] = []*models.AlertRule{ - models.AlertRuleGen(withOrgID(1), withUID("my-rule"))(), + models.RuleGen.With(models.RuleMuts.WithOrgID(1), withUID("my-rule")).GenerateRef(), } dbs := &dashboards.FakeDashboardService{} dbs.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{}, nil) @@ -169,12 +170,6 @@ func createAnnotation() annotations.Item { } } -func withOrgID(orgId int64) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.OrgID = orgId - } -} - func TestBuildAnnotations(t *testing.T) { t.Run("data wraps nil values when values are nil", func(t *testing.T) { logger := log.NewNopLogger() @@ -230,7 +225,7 @@ func makeStateTransition() state.StateTransition { } } -func withUID(uid string) func(rule *models.AlertRule) { +func withUID(uid string) models.AlertRuleMutator { return func(rule *models.AlertRule) { rule.UID = uid } diff --git a/pkg/services/ngalert/state/manager_private_test.go b/pkg/services/ngalert/state/manager_private_test.go index 798b37a0720bd..39c663752190f 100644 --- a/pkg/services/ngalert/state/manager_private_test.go +++ b/pkg/services/ngalert/state/manager_private_test.go @@ -146,11 +146,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { } baseRuleWith := func(mutators ...ngmodels.AlertRuleMutator) *ngmodels.AlertRule { - r := ngmodels.CopyRule(baseRule) - for _, mutator := range mutators { - mutator(r) - } - return r + return ngmodels.CopyRule(baseRule, mutators...) } newEvaluation := func(evalTime time.Time, evalState eval.State) Evaluation { @@ -397,7 +393,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:alerting,2:normal] and 'for'>0 at t1", - alertRule: baseRuleWith(ngmodels.WithForNTimes(3)), + alertRule: baseRuleWith(ngmodels.RuleMuts.WithForNTimes(3)), results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -483,7 +479,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:alerting] t2[1:alerting] t3[1:alerting] and 'for'=2 at t1,t2,t3", - alertRule: baseRuleWith(ngmodels.WithForNTimes(2)), + alertRule: baseRuleWith(ngmodels.RuleMuts.WithForNTimes(2)), results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -547,7 +543,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:alerting], t2[1:normal] and 'for'=2 at t2", - alertRule: baseRuleWith(ngmodels.WithForNTimes(2)), + alertRule: baseRuleWith(ngmodels.RuleMuts.WithForNTimes(2)), results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -740,7 +736,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[{}:alerting] and 'for'>0 at t1", - alertRule: baseRuleWith(ngmodels.WithForNTimes(3)), + alertRule: baseRuleWith(ngmodels.RuleMuts.WithForNTimes(3)), results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting)), @@ -809,10 +805,10 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { t.Run("no-data", func(t *testing.T) { rules := map[ngmodels.NoDataState]*ngmodels.AlertRule{ - ngmodels.NoData: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.NoData)), - ngmodels.Alerting: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.Alerting)), - ngmodels.OK: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.OK)), - ngmodels.KeepLast: baseRuleWith(ngmodels.WithNoDataExecAs(ngmodels.KeepLast)), + ngmodels.NoData: baseRuleWith(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.NoData)), + ngmodels.Alerting: baseRuleWith(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.Alerting)), + ngmodels.OK: baseRuleWith(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.OK)), + ngmodels.KeepLast: baseRuleWith(ngmodels.RuleMuts.WithNoDataExecAs(ngmodels.KeepLast)), } type noDataTestCase struct { @@ -829,10 +825,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { for stateExec, rule := range rules { r := rule if len(tc.ruleMutators) > 0 { - r = ngmodels.CopyRule(r) - for _, mutateRule := range tc.ruleMutators { - mutateRule(r) - } + r = ngmodels.CopyRule(r, tc.ruleMutators...) } t.Run(fmt.Sprintf("execute as %s", stateExec), func(t *testing.T) { expectedTransitions, ok := tc.expectedTransitionsApplyNoDataErrorToAllStates[stateExec] @@ -1524,7 +1517,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:normal,2:alerting] t2[NoData] t3[NoData] and 'for'=1 at t2*,t3", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(1)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(1)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1953,7 +1946,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:alerting] t2[NoData] t3[1:alerting] and 'for'=2 at t3", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(2)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(2)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -2678,7 +2671,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[{}:alerting] t2[NoData] t3[{}:alerting] and 'for'=2 at t2*,t3", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(2)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(2)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting)), @@ -2850,10 +2843,10 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { t.Run("error", func(t *testing.T) { rules := map[ngmodels.ExecutionErrorState]*ngmodels.AlertRule{ - ngmodels.ErrorErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.ErrorErrState)), - ngmodels.AlertingErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.AlertingErrState)), - ngmodels.OkErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.OkErrState)), - ngmodels.KeepLastErrState: baseRuleWith(ngmodels.WithErrorExecAs(ngmodels.KeepLastErrState)), + ngmodels.ErrorErrState: baseRuleWith(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.ErrorErrState)), + ngmodels.AlertingErrState: baseRuleWith(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.AlertingErrState)), + ngmodels.OkErrState: baseRuleWith(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.OkErrState)), + ngmodels.KeepLastErrState: baseRuleWith(ngmodels.RuleMuts.WithErrorExecAs(ngmodels.KeepLastErrState)), } cacheID := func(lbls data.Labels) string { @@ -2879,10 +2872,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { for stateExec, rule := range rules { r := rule if len(tc.ruleMutators) > 0 { - r = ngmodels.CopyRule(r) - for _, mutateRule := range tc.ruleMutators { - mutateRule(r) - } + r = ngmodels.CopyRule(r, tc.ruleMutators...) } t.Run(fmt.Sprintf("execute as %s", stateExec), func(t *testing.T) { expectedTransitions, ok := tc.expectedTransitionsApplyNoDataErrorToAllStates[stateExec] @@ -3084,7 +3074,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[1:alerting] t2[QueryError] and 'for'=1 at t2", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(1)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(1)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -3630,7 +3620,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[{}:alerting] t2[QueryError] and 'for'=1 at t1*,t2", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(1)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(1)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting)), @@ -3736,7 +3726,7 @@ func TestProcessEvalResults_StateTransitions(t *testing.T) { }, { desc: "t1[{}:alerting] t2[QueryError] t3[{}:alerting] and 'for'=2 at t2,t3", - ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.WithForNTimes(2)}, + ruleMutators: []ngmodels.AlertRuleMutator{ngmodels.RuleMuts.WithForNTimes(2)}, results: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting)), diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 38a7519f84688..dbe6b697a1514 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -311,7 +311,7 @@ func TestProcessEvalResults(t *testing.T) { t2 := tn(2) t3 := tn(3) - + m := models.RuleMuts baseRule := &models.AlertRule{ OrgID: 1, Title: "test_title", @@ -340,10 +340,7 @@ func TestProcessEvalResults(t *testing.T) { } baseRuleWith := func(mutators ...models.AlertRuleMutator) *models.AlertRule { - r := models.CopyRule(baseRule) - for _, mutator := range mutators { - mutator(r) - } + r := models.CopyRule(baseRule, mutators...) return r } @@ -500,7 +497,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting when For is set", - alertRule: baseRuleWith(models.WithForNTimes(2)), + alertRule: baseRuleWith(m.WithForNTimes(2)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -533,7 +530,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting -> noData -> alerting when For is set", - alertRule: baseRuleWith(models.WithForNTimes(2)), + alertRule: baseRuleWith(m.WithForNTimes(2)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -569,7 +566,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "pending -> alerting -> noData when For is set and NoDataState is NoData", - alertRule: baseRuleWith(models.WithForNTimes(2)), + alertRule: baseRuleWith(m.WithForNTimes(2)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -602,7 +599,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending when For is set but not exceeded and first result is normal", - alertRule: baseRuleWith(models.WithForNTimes(2)), + alertRule: baseRuleWith(m.WithForNTimes(2)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -630,7 +627,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending when For is set but not exceeded and first result is alerting", - alertRule: baseRuleWith(models.WithForNTimes(6)), + alertRule: baseRuleWith(m.WithForNTimes(6)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -657,7 +654,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending when For is set but not exceeded, result is NoData and NoDataState is alerting", - alertRule: baseRuleWith(models.WithForNTimes(6), models.WithNoDataExecAs(models.Alerting)), + alertRule: baseRuleWith(m.WithForNTimes(6), m.WithNoDataExecAs(models.Alerting)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -685,7 +682,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting when For is exceeded, result is NoData and NoDataState is alerting", - alertRule: baseRuleWith(models.WithForNTimes(3), models.WithNoDataExecAs(models.Alerting)), + alertRule: baseRuleWith(m.WithForNTimes(3), m.WithNoDataExecAs(models.Alerting)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -877,7 +874,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> normal (NoData, KeepLastState) -> alerting -> alerting (NoData, KeepLastState) - keeps last state when result is NoData and NoDataState is KeepLast", - alertRule: baseRuleWith(models.WithForNTimes(0), models.WithNoDataExecAs(models.KeepLast)), + alertRule: baseRuleWith(m.WithForNTimes(0), m.WithNoDataExecAs(models.KeepLast)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -913,7 +910,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending -> pending (NoData, KeepLastState) -> alerting (NoData, KeepLastState) - keep last state respects For when result is NoData", - alertRule: baseRuleWith(models.WithForNTimes(2), models.WithNoDataExecAs(models.KeepLast)), + alertRule: baseRuleWith(m.WithForNTimes(2), m.WithNoDataExecAs(models.KeepLast)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -947,7 +944,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> normal when result is NoData and NoDataState is ok", - alertRule: baseRuleWith(models.WithNoDataExecAs(models.OK)), + alertRule: baseRuleWith(m.WithNoDataExecAs(models.OK)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -975,7 +972,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending when For is set but not exceeded, result is Error and ExecErrState is Alerting", - alertRule: baseRuleWith(models.WithForNTimes(6), models.WithErrorExecAs(models.AlertingErrState)), + alertRule: baseRuleWith(m.WithForNTimes(6), m.WithErrorExecAs(models.AlertingErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1004,7 +1001,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting when For is exceeded, result is Error and ExecErrState is Alerting", - alertRule: baseRuleWith(models.WithForNTimes(3), models.WithErrorExecAs(models.AlertingErrState)), + alertRule: baseRuleWith(m.WithForNTimes(3), m.WithErrorExecAs(models.AlertingErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1043,7 +1040,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> error when result is Error and ExecErrState is Error", - alertRule: baseRuleWith(models.WithForNTimes(6), models.WithErrorExecAs(models.ErrorErrState)), + alertRule: baseRuleWith(m.WithForNTimes(6), m.WithErrorExecAs(models.ErrorErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1084,7 +1081,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> normal (Error, KeepLastState) -> alerting -> alerting (Error, KeepLastState) - keeps last state when result is Error and ExecErrState is KeepLast", - alertRule: baseRuleWith(models.WithForNTimes(0), models.WithErrorExecAs(models.KeepLastErrState)), + alertRule: baseRuleWith(m.WithForNTimes(0), m.WithErrorExecAs(models.KeepLastErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1120,7 +1117,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> pending -> pending (Error, KeepLastState) -> alerting (Error, KeepLastState) - keep last state respects For when result is Error", - alertRule: baseRuleWith(models.WithForNTimes(2), models.WithErrorExecAs(models.KeepLastErrState)), + alertRule: baseRuleWith(m.WithForNTimes(2), m.WithErrorExecAs(models.KeepLastErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1154,7 +1151,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> normal when result is Error and ExecErrState is OK", - alertRule: baseRuleWith(models.WithForNTimes(6), models.WithErrorExecAs(models.OkErrState)), + alertRule: baseRuleWith(m.WithForNTimes(6), m.WithErrorExecAs(models.OkErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1182,7 +1179,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "alerting -> normal when result is Error and ExecErrState is OK", - alertRule: baseRuleWith(models.WithForNTimes(6), models.WithErrorExecAs(models.OkErrState)), + alertRule: baseRuleWith(m.WithForNTimes(6), m.WithErrorExecAs(models.OkErrState)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -1210,7 +1207,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting -> error when result is Error and ExecErrorState is Error", - alertRule: baseRuleWith(models.WithForNTimes(2)), + alertRule: baseRuleWith(m.WithForNTimes(2)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Alerting), eval.WithLabels(labels1)), @@ -1250,7 +1247,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting -> error -> alerting - it should clear the error", - alertRule: baseRuleWith(models.WithForNTimes(3)), + alertRule: baseRuleWith(m.WithForNTimes(3)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1284,7 +1281,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "normal -> alerting -> error -> no data - it should clear the error", - alertRule: baseRuleWith(models.WithForNTimes(3)), + alertRule: baseRuleWith(m.WithForNTimes(3)), evalResults: map[time.Time]eval.Results{ t1: { newResult(eval.WithState(eval.Normal), eval.WithLabels(labels1)), @@ -1319,8 +1316,8 @@ func TestProcessEvalResults(t *testing.T) { { desc: "template is correctly expanded", alertRule: baseRuleWith( - models.WithAnnotations(map[string]string{"summary": "{{$labels.pod}} is down in {{$labels.cluster}} cluster -> {{$labels.namespace}} namespace"}), - models.WithLabels(map[string]string{"label": "test", "job": "{{$labels.namespace}}/{{$labels.pod}}"}), + m.WithAnnotations(map[string]string{"summary": "{{$labels.pod}} is down in {{$labels.cluster}} cluster -> {{$labels.namespace}} namespace"}), + m.WithLabels(map[string]string{"label": "test", "job": "{{$labels.namespace}}/{{$labels.pod}}"}), ), evalResults: map[time.Time]eval.Results{ t1: { @@ -1359,7 +1356,7 @@ func TestProcessEvalResults(t *testing.T) { }, { desc: "classic condition, execution Error as Error (alerting -> query error -> alerting)", - alertRule: baseRuleWith(models.WithErrorExecAs(models.ErrorErrState)), + alertRule: baseRuleWith(m.WithErrorExecAs(models.ErrorErrState)), expectedAnnotations: 3, evalResults: map[time.Time]eval.Results{ t1: { @@ -1512,7 +1509,7 @@ func TestProcessEvalResults(t *testing.T) { } statePersister := state.NewSyncStatePersisiter(log.New("ngalert.state.manager.persist"), cfg) st := state.NewManager(cfg, statePersister) - rule := models.AlertRuleGen()() + rule := models.RuleGen.GenerateRef() var results = eval.GenerateResults(rand.Intn(4)+1, eval.ResultGen(eval.WithEvaluatedAt(clk.Now()))) states := st.ProcessEvalResults(context.Background(), clk.Now(), rule, results, make(data.Labels)) @@ -1747,7 +1744,8 @@ func TestStaleResults(t *testing.T) { } st := state.NewManager(cfg, state.NewNoopPersister()) - rule := models.AlertRuleGen(models.WithFor(0))() + gen := models.RuleGen + rule := gen.With(gen.WithFor(0)).GenerateRef() initResults := eval.Results{ eval.ResultGen(eval.WithEvaluatedAt(clk.Now()))(), diff --git a/pkg/services/ngalert/state/state_test.go b/pkg/services/ngalert/state/state_test.go index 1684b7036c17f..7bb69b7d43fd1 100644 --- a/pkg/services/ngalert/state/state_test.go +++ b/pkg/services/ngalert/state/state_test.go @@ -706,8 +706,7 @@ func TestParseFormattedState(t *testing.T) { func TestGetRuleExtraLabels(t *testing.T) { logger := log.New() - rule := ngmodels.AlertRuleGen()() - rule.NotificationSettings = nil + rule := ngmodels.RuleGen.With(ngmodels.RuleMuts.WithNoNotificationSettings()).GenerateRef() folderTitle := uuid.NewString() ns := ngmodels.NotificationSettings{ diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index fe470991d7320..4958c69b5f31d 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "sync" "testing" "time" @@ -49,10 +48,11 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), Logger: &logtest.Fake{}, } - generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID()) + gen := models.RuleGen + gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval)) t.Run("should increase version", func(t *testing.T) { - rule := createRule(t, store, generator) + rule := createRule(t, store, gen) newRule := models.CopyRule(rule) newRule.Title = util.GenerateShortUID() err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{ @@ -74,7 +74,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { }) t.Run("should fail due to optimistic locking if version does not match", func(t *testing.T) { - rule := createRule(t, store, generator) + rule := createRule(t, store, gen) rule.Version-- // simulate version discrepancy newRule := models.CopyRule(rule) @@ -104,13 +104,14 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) Logger: &logtest.Fake{}, } - idMutator := models.WithUniqueID() + gen := models.RuleGen createRuleInFolder := func(title string, orgID int64, namespaceUID string) *models.AlertRule { - generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), idMutator, models.WithNamespace(&folder.Folder{ - UID: namespaceUID, - Title: namespaceUID, - }), withOrgID(orgID), models.WithTitle(title)) - return createRule(t, store, generator) + gen := gen.With( + gen.WithOrgID(orgID), + gen.WithIntervalMatching(store.Cfg.BaseInterval), + gen.WithNamespaceUID(namespaceUID), + ) + return createRule(t, store, gen) } t.Run("should handle update chains without unique constraint violation", func(t *testing.T) { @@ -360,9 +361,11 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { FeatureToggles: featuremgmt.WithFeatures(), } - generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID()) - rule1 := createRule(t, store, generator) - rule2 := createRule(t, store, generator) + gen := models.RuleGen + gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval), gen.WithUniqueOrgID()) + + rule1 := createRule(t, store, gen) + rule2 := createRule(t, store, gen) parentFolderUid := uuid.NewString() parentFolderTitle := "Very Parent Folder" @@ -372,7 +375,7 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { createFolder(t, store, rule1.NamespaceUID, rule1FolderTitle, rule1.OrgID, parentFolderUid) createFolder(t, store, rule2.NamespaceUID, rule2FolderTitle, rule2.OrgID, "") - createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID, "") // create a folder with the same UID but in the different org + createFolder(t, store, rule2.NamespaceUID, "same UID folder", gen.GenerateRef().OrgID, "") // create a folder with the same UID but in the different org tc := []struct { name string @@ -458,13 +461,6 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { }) } -func withIntervalMatching(baseInterval time.Duration) func(*models.AlertRule) { - return func(rule *models.AlertRule) { - rule.IntervalSeconds = int64(baseInterval.Seconds()) * (rand.Int63n(10) + 1) - rule.For = time.Duration(rule.IntervalSeconds*rand.Int63n(9)+1) * time.Second - } -} - func TestIntegration_CountAlertRules(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -614,7 +610,12 @@ func TestIntegrationInsertAlertRules(t *testing.T) { Cfg: cfg.UnifiedAlerting, } - rules := models.GenerateAlertRules(5, models.AlertRuleGen(models.WithOrgID(orgID), withIntervalMatching(store.Cfg.BaseInterval))) + gen := models.RuleGen + rules := gen.With( + gen.WithOrgID(orgID), + gen.WithIntervalMatching(store.Cfg.BaseInterval), + ).GenerateManyRef(5) + deref := make([]models.AlertRule, 0, len(rules)) for _, rule := range rules { deref = append(deref, *rule) @@ -683,21 +684,14 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { Cfg: cfg.UnifiedAlerting, } - uniqueUids := &sync.Map{} receiverName := "receiver\"-" + uuid.NewString() - rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(1), withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueUID(uniqueUids))) - receiveRules := models.GenerateAlertRules(3, - models.AlertRuleGen( - models.WithOrgID(1), - withIntervalMatching(store.Cfg.BaseInterval), - models.WithUniqueUID(uniqueUids), - models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))))) - noise := models.GenerateAlertRules(3, - models.AlertRuleGen( - models.WithOrgID(1), - withIntervalMatching(store.Cfg.BaseInterval), - models.WithUniqueUID(uniqueUids), - models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithMuteTimeIntervals(receiverName))))) // simulate collision of names of receiver and mute timing + + gen := models.RuleGen + gen = gen.With(gen.WithOrgID(1), gen.WithIntervalMatching(store.Cfg.BaseInterval)) + rules := gen.GenerateManyRef(3) + receiveRules := gen.With(gen.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName)))).GenerateManyRef(3) + noise := gen.With(gen.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithMuteTimeIntervals(receiverName)))).GenerateManyRef(3) + deref := make([]models.AlertRule, 0, len(rules)+len(receiveRules)+len(noise)) for _, rule := range append(append(rules, receiveRules...), noise...) { r := *rule @@ -768,36 +762,21 @@ func TestIntegrationListNotificationSettings(t *testing.T) { Cfg: cfg.UnifiedAlerting, } - uids := &sync.Map{} - titles := &sync.Map{} receiverName := `receiver%"-👍'test` - rulesWithNotifications := models.GenerateAlertRules(5, models.AlertRuleGen( - models.WithOrgID(1), - models.WithUniqueUID(uids), - models.WithUniqueTitle(titles), - withIntervalMatching(store.Cfg.BaseInterval), - models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))), - )) - rulesInOtherOrg := models.GenerateAlertRules(5, models.AlertRuleGen( - models.WithOrgID(2), - models.WithUniqueUID(uids), - models.WithUniqueTitle(titles), - withIntervalMatching(store.Cfg.BaseInterval), - models.WithNotificationSettingsGen(models.NotificationSettingsGen()), - )) - rulesWithNoNotifications := models.GenerateAlertRules(5, models.AlertRuleGen( - models.WithOrgID(1), - models.WithUniqueUID(uids), - models.WithUniqueTitle(titles), - withIntervalMatching(store.Cfg.BaseInterval), - models.WithNoNotificationSettings(), - )) - deref := make([]models.AlertRule, 0, len(rulesWithNotifications)+len(rulesWithNoNotifications)+len(rulesInOtherOrg)) - for _, rule := range append(append(rulesWithNotifications, rulesWithNoNotifications...), rulesInOtherOrg...) { - r := *rule - r.ID = 0 - deref = append(deref, r) - } + gen := models.RuleGen + gen = gen.With(gen.WithOrgID(1), gen.WithIntervalMatching(store.Cfg.BaseInterval)) + + rulesWithNotifications := gen.With( + gen.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))), + ).GenerateMany(5) + rulesInOtherOrg := gen.With( + gen.WithOrgID(2), + gen.WithNotificationSettingsGen(models.NotificationSettingsGen()), + ).GenerateMany(5) + + rulesWithNoNotifications := gen.With(gen.WithNoNotificationSettings()).GenerateMany(5) + + deref := append(append(rulesWithNotifications, rulesWithNoNotifications...), rulesInOtherOrg...) _, err := store.InsertAlertRules(context.Background(), deref) require.NoError(t, err) @@ -832,12 +811,12 @@ func TestIntegrationListNotificationSettings(t *testing.T) { // createAlertRule creates an alert rule in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) *models.AlertRule { +func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule { t.Helper() - if generate == nil { - generate = models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval)) + if generator == nil { + generator = models.RuleGen.With(models.RuleMuts.WithIntervalMatching(store.Cfg.BaseInterval)) } - rule := generate() + rule := generator.GenerateRef() err := store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error { _, err := sess.Table(models.AlertRule{}).InsertOne(rule) if err != nil { diff --git a/pkg/services/ngalert/store/deltas_test.go b/pkg/services/ngalert/store/deltas_test.go index 5a39e4cc787fb..2ae30ae5ef008 100644 --- a/pkg/services/ngalert/store/deltas_test.go +++ b/pkg/services/ngalert/store/deltas_test.go @@ -19,15 +19,16 @@ import ( func TestCalculateChanges(t *testing.T) { orgId := int64(rand.Int31()) + gen := models.RuleGen t.Run("detects alerts that need to be added", func(t *testing.T) { fakeStore := fakes.NewRuleStore(t) groupKey := models.GenerateGroupKey(orgId) - rules := models.GenerateAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)) + rules := gen.With(gen.WithOrgID(orgId), simulateSubmitted, withoutUID).GenerateMany(1, 5) submitted := make([]*models.AlertRuleWithOptionals, 0, len(rules)) for _, rule := range rules { - submitted = append(submitted, &models.AlertRuleWithOptionals{AlertRule: *rule}) + submitted = append(submitted, &models.AlertRuleWithOptionals{AlertRule: rule}) } changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, submitted) @@ -50,8 +51,8 @@ func TestCalculateChanges(t *testing.T) { t.Run("detects alerts that need to be deleted", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) - inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) - + inDatabase := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(1, 5) + inDatabaseMap := groupByUID(t, inDatabase) fakeStore := fakes.NewRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) @@ -73,8 +74,11 @@ func TestCalculateChanges(t *testing.T) { t.Run("should detect alerts that needs to be updated", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) - inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) - submittedMap, rules := models.GenerateUniqueAlertRules(len(inDatabase), models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap))) + inDatabase := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(1, 5) + inDatabaseMap := groupByUID(t, inDatabase) + + rules := gen.With(simulateSubmitted, gen.WithGroupKey(groupKey), withUIDs(inDatabaseMap)).GenerateManyRef(len(inDatabase), len(inDatabase)) + submittedMap := groupByUID(t, rules) submitted := make([]*models.AlertRuleWithOptionals, 0, len(rules)) for _, rule := range rules { submitted = append(submitted, &models.AlertRuleWithOptionals{AlertRule: *rule}) @@ -104,7 +108,7 @@ func TestCalculateChanges(t *testing.T) { t.Run("should include only if there are changes ignoring specific fields", func(t *testing.T) { groupKey := models.GenerateGroupKey(orgId) - _, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(5)+1, models.AlertRuleGen(withGroupKey(groupKey))) + inDatabase := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(1, 5) submitted := make([]*models.AlertRuleWithOptionals, 0, len(inDatabase)) for _, rule := range inDatabase { @@ -132,7 +136,7 @@ func TestCalculateChanges(t *testing.T) { t.Run("should patch rule with UID specified by existing rule", func(t *testing.T) { testCases := []struct { name string - mutator func(r *models.AlertRule) + mutator models.AlertRuleMutator }{ { name: "title is empty", @@ -167,7 +171,7 @@ func TestCalculateChanges(t *testing.T) { }, } - dbRule := models.AlertRuleGen(withOrgID(orgId))() + dbRule := gen.With(gen.WithOrgID(orgId)).GenerateRef() fakeStore := fakes.NewRuleStore(t) fakeStore.PutRule(context.Background(), dbRule) @@ -176,7 +180,7 @@ func TestCalculateChanges(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - expected := models.AlertRuleGen(simulateSubmitted, testCase.mutator)() + expected := gen.With(simulateSubmitted, testCase.mutator).GenerateRef() expected.UID = dbRule.UID submitted := *expected changes, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: submitted}}) @@ -193,7 +197,8 @@ func TestCalculateChanges(t *testing.T) { t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) { sourceGroupKey := models.GenerateGroupKey(orgId) - inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withGroupKey(sourceGroupKey))) + inDatabase := gen.With(gen.WithGroupKey(sourceGroupKey)).GenerateManyRef(10, 20) + inDatabaseMap := groupByUID(t, inDatabase) fakeStore := fakes.NewRuleStore(t) fakeStore.PutRule(context.Background(), inDatabase...) @@ -207,7 +212,8 @@ func TestCalculateChanges(t *testing.T) { RuleGroup: groupName, } - submittedMap, rules := models.GenerateUniqueAlertRules(rand.Intn(len(inDatabase)-5)+5, models.AlertRuleGen(simulateSubmitted, withGroupKey(groupKey), withUIDs(inDatabaseMap))) + rules := gen.With(simulateSubmitted, gen.WithGroupKey(groupKey), withUIDs(inDatabaseMap)).GenerateManyRef(5, len(inDatabase)) + submittedMap := groupByUID(t, rules) submitted := make([]*models.AlertRuleWithOptionals, 0, len(rules)) for _, rule := range rules { submitted = append(submitted, &models.AlertRuleWithOptionals{AlertRule: *rule}) @@ -237,10 +243,10 @@ func TestCalculateChanges(t *testing.T) { t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) { fakeStore := fakes.NewRuleStore(t) groupKey := models.GenerateGroupKey(orgId) - submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() + submitted := gen.With(gen.WithOrgID(orgId), simulateSubmitted).Generate() require.NotEqual(t, "", submitted.UID) - _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: *submitted}}) + _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: submitted}}) require.Error(t, err) }) @@ -256,9 +262,9 @@ func TestCalculateChanges(t *testing.T) { } groupKey := models.GenerateGroupKey(orgId) - submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted, withoutUID)() + submitted := gen.With(gen.WithOrgID(orgId), simulateSubmitted, withoutUID).Generate() - _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: *submitted}}) + _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: submitted}}) require.ErrorIs(t, err, expectedErr) }) @@ -274,19 +280,20 @@ func TestCalculateChanges(t *testing.T) { } groupKey := models.GenerateGroupKey(orgId) - submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)() + submitted := gen.With(gen.WithOrgID(orgId), simulateSubmitted).Generate() - _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: *submitted}}) + _, err := CalculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRuleWithOptionals{{AlertRule: submitted}}) require.ErrorIs(t, err, expectedErr) }) } func TestCalculateAutomaticChanges(t *testing.T) { orgID := rand.Int63() + gen := models.RuleGen t.Run("should mark all rules in affected groups", func(t *testing.T) { group := models.GenerateGroupKey(orgID) - rules := models.GenerateAlertRules(10, models.AlertRuleGen(withGroupKey(group))) + rules := gen.With(gen.WithGroupKey(group)).GenerateManyRef(10) // copy rules to make sure that the function does not modify the original rules copies := make([]*models.AlertRule, 0, len(rules)) for _, rule := range rules { @@ -309,7 +316,7 @@ func TestCalculateAutomaticChanges(t *testing.T) { AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{ group: copies, }, - New: models.GenerateAlertRules(2, models.AlertRuleGen(withGroupKey(group))), + New: gen.With(gen.WithGroupKey(group)).GenerateManyRef(2), Update: updates, Delete: rules[5:7], } @@ -337,9 +344,9 @@ func TestCalculateAutomaticChanges(t *testing.T) { t.Run("should re-index rules in affected groups other than updated", func(t *testing.T) { group := models.GenerateGroupKey(orgID) - rules := models.GenerateAlertRules(3, models.AlertRuleGen(withGroupKey(group), models.WithSequentialGroupIndex())) + rules := gen.With(gen.WithGroupKey(group), gen.WithSequentialGroupIndex()).GenerateManyRef(3) group2 := models.GenerateGroupKey(orgID) - rules2 := models.GenerateAlertRules(4, models.AlertRuleGen(withGroupKey(group2), models.WithSequentialGroupIndex())) + rules2 := gen.With(gen.WithGroupKey(group2), gen.WithSequentialGroupIndex()).GenerateManyRef(4) movedIndex := rand.Intn(len(rules2)) movedRule := rules2[movedIndex] @@ -417,9 +424,10 @@ func TestCalculateAutomaticChanges(t *testing.T) { } func TestCalculateRuleGroupDelete(t *testing.T) { + gen := models.RuleGen fakeStore := fakes.NewRuleStore(t) groupKey := models.GenerateGroupKey(1) - otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(groupKey.OrgID), models.WithNamespaceUIDNotIn(groupKey.NamespaceUID))) + otherRules := gen.With(gen.WithOrgID(groupKey.OrgID), gen.WithNamespaceUIDNotIn(groupKey.NamespaceUID)).GenerateManyRef(3) fakeStore.Rules[groupKey.OrgID] = otherRules t.Run("NotFound when group does not exist", func(t *testing.T) { @@ -429,7 +437,7 @@ func TestCalculateRuleGroupDelete(t *testing.T) { }) t.Run("set AffectedGroups when a rule refers to an existing group", func(t *testing.T) { - groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey))) + groupRules := gen.With(gen.WithGroupKey(groupKey)).GenerateManyRef(3) fakeStore.Rules[groupKey.OrgID] = append(fakeStore.Rules[groupKey.OrgID], groupRules...) delta, err := CalculateRuleGroupDelete(context.Background(), fakeStore, groupKey) @@ -447,9 +455,10 @@ func TestCalculateRuleGroupDelete(t *testing.T) { } func TestCalculateRuleDelete(t *testing.T) { + gen := models.RuleGen fakeStore := fakes.NewRuleStore(t) - rule := models.AlertRuleGen()() - otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID))) + rule := gen.GenerateRef() + otherRules := gen.With(gen.WithOrgID(rule.OrgID), gen.WithNamespaceUIDNotIn(rule.NamespaceUID)).GenerateManyRef(3) fakeStore.Rules[rule.OrgID] = otherRules t.Run("nil when a rule does not exist", func(t *testing.T) { @@ -459,7 +468,7 @@ func TestCalculateRuleDelete(t *testing.T) { }) t.Run("set AffectedGroups when a rule refers to an existing group", func(t *testing.T) { - groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey()))) + groupRules := gen.With(gen.WithGroupKey(rule.GetGroupKey())).GenerateManyRef(3) groupRules = append(groupRules, rule) fakeStore.Rules[rule.OrgID] = append(fakeStore.Rules[rule.OrgID], groupRules...) @@ -479,10 +488,11 @@ func TestCalculateRuleDelete(t *testing.T) { } func TestCalculateRuleUpdate(t *testing.T) { + gen := models.RuleGen fakeStore := fakes.NewRuleStore(t) - rule := models.AlertRuleGen()() - otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID))) - groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey()))) + rule := gen.GenerateRef() + otherRules := gen.With(gen.WithOrgID(rule.OrgID), gen.WithNamespaceUIDNotIn(rule.NamespaceUID)).GenerateManyRef(3) + groupRules := gen.With(gen.WithGroupKey(rule.GetGroupKey())).GenerateManyRef(3) groupRules = append(groupRules, rule) fakeStore.Rules[rule.OrgID] = append(otherRules, groupRules...) @@ -520,7 +530,7 @@ func TestCalculateRuleUpdate(t *testing.T) { t.Run("when a rule is moved between groups", func(t *testing.T) { sourceGroupKey := rule.GetGroupKey() targetGroupKey := models.GenerateGroupKey(rule.OrgID) - targetGroup := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(targetGroupKey))) + targetGroup := gen.With(gen.WithGroupKey(targetGroupKey)).GenerateManyRef(3) fakeStore.Rules[rule.OrgID] = append(fakeStore.Rules[rule.OrgID], targetGroup...) cp := models.CopyRule(rule) @@ -548,9 +558,10 @@ func TestCalculateRuleUpdate(t *testing.T) { } func TestCalculateRuleCreate(t *testing.T) { + gen := models.RuleGen t.Run("when a rule refers to a new group", func(t *testing.T) { fakeStore := fakes.NewRuleStore(t) - rule := models.AlertRuleGen()() + rule := gen.GenerateRef() delta, err := CalculateRuleCreate(context.Background(), fakeStore, rule) require.NoError(t, err) @@ -565,10 +576,10 @@ func TestCalculateRuleCreate(t *testing.T) { t.Run("when a rule refers to an existing group", func(t *testing.T) { fakeStore := fakes.NewRuleStore(t) - rule := models.AlertRuleGen()() + rule := gen.GenerateRef() - groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey()))) - otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID))) + groupRules := gen.With(gen.WithGroupKey(rule.GetGroupKey())).GenerateManyRef(3) + otherRules := gen.With(gen.WithGroupKey(rule.GetGroupKey()), gen.WithNamespaceUIDNotIn(rule.NamespaceUID)).GenerateManyRef(3) fakeStore.Rules[rule.OrgID] = append(groupRules, otherRules...) delta, err := CalculateRuleCreate(context.Background(), fakeStore, rule) @@ -591,25 +602,11 @@ func simulateSubmitted(rule *models.AlertRule) { rule.Updated = time.Time{} } -func withOrgID(orgId int64) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.OrgID = orgId - } -} - func withoutUID(rule *models.AlertRule) { rule.UID = "" } -func withGroupKey(groupKey models.AlertRuleGroupKey) func(rule *models.AlertRule) { - return func(rule *models.AlertRule) { - rule.RuleGroup = groupKey.RuleGroup - rule.OrgID = groupKey.OrgID - rule.NamespaceUID = groupKey.NamespaceUID - } -} - -func withUIDs(uids map[string]*models.AlertRule) func(rule *models.AlertRule) { +func withUIDs(uids map[string]*models.AlertRule) models.AlertRuleMutator { unused := make([]string, 0, len(uids)) for s := range uids { unused = append(unused, s) @@ -635,3 +632,14 @@ func randFolder() *folder.Folder { CreatedBy: 0, } } + +func groupByUID(t *testing.T, list []*models.AlertRule) map[string]*models.AlertRule { + result := make(map[string]*models.AlertRule, len(list)) + for _, rule := range list { + if _, ok := result[rule.UID]; ok { + t.Fatalf("expected unique UID for rule %s but duplicate", rule.UID) + } + result[rule.UID] = rule + } + return result +} From 0f4db3f5ad2316ec1dbb3672535abbeb2fe150bb Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 30 Apr 2024 07:58:25 +0200 Subject: [PATCH 45/53] Fix: yarn build in DockerFile (#86858) --- Dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7a68afa3b004f..04991627ceb40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,18 +14,18 @@ ENV NODE_OPTIONS=--max_old_space_size=8000 WORKDIR /tmp/grafana -COPY package.json yarn.lock .yarnrc.yml ./ +COPY package.json project.json nx.json yarn.lock .yarnrc.yml ./ COPY .yarn .yarn COPY packages packages COPY plugins-bundled plugins-bundled COPY public public +COPY LICENSE ./ RUN apk add --no-cache make build-base python3 RUN yarn install --immutable COPY tsconfig.json .eslintrc .editorconfig .browserslistrc .prettierrc.js ./ -COPY public public COPY scripts scripts COPY emails emails @@ -77,7 +77,6 @@ COPY pkg pkg COPY scripts scripts COPY conf conf COPY .github .github -COPY LICENSE ./ ENV COMMIT_SHA=${COMMIT_SHA} ENV BUILD_BRANCH=${BUILD_BRANCH} @@ -179,7 +178,7 @@ RUN if [ ! $(getent group "$GF_GID") ]; then \ COPY --from=go-src /tmp/grafana/bin/grafana* /tmp/grafana/bin/*/grafana* ./bin/ COPY --from=js-src /tmp/grafana/public ./public -COPY --from=go-src /tmp/grafana/LICENSE ./ +COPY --from=js-src /tmp/grafana/LICENSE ./ EXPOSE 3000 From 1cb3f332a17923f58cfa75cdb9d5eaab7ed66f45 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 30 Apr 2024 08:54:20 +0200 Subject: [PATCH 46/53] Chore: Remove extra sql select from the Insert function of userimpl.store (#87060) Remove getAnyUserType --- pkg/services/user/userimpl/store.go | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 597b649d3aa70..d4c8d2513a108 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -75,11 +75,6 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { return 0, err } - // verify that user was created and cmd.ID was updated with the actual new userID - _, err = ss.getAnyUserType(ctx, cmd.ID) - if err != nil { - return 0, err - } return cmd.ID, nil } @@ -588,22 +583,6 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* return &result, err } -// getAnyUserType searches for a user record by ID. The user account may be a service account. -func (ss *sqlStore) getAnyUserType(ctx context.Context, userID int64) (*user.User, error) { - usr := user.User{ID: userID} - err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { - has, err := sess.Get(&usr) - if err != nil { - return err - } - if !has { - return user.ErrUserNotFound - } - return nil - }) - return &usr, err -} - func setOptional[T any](v *T, add func(v T)) { if v != nil { add(*v) From 78cda7ff5c7f2965c4ca2050fa047bbaaa5b5b00 Mon Sep 17 00:00:00 2001 From: Andreas 'count' Kotes Date: Tue, 30 Apr 2024 09:21:49 +0200 Subject: [PATCH 47/53] Schema: add missing insertNulls to GraphFieldConfig (#85861) add missing insertNulls to GraphFieldConfig Co-authored-by: joshhunt --- packages/grafana-schema/src/common/common.gen.ts | 1 + packages/grafana-schema/src/common/mudball.cue | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index a0c69ebbb060a..6fca2bf56fd6e 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -601,6 +601,7 @@ export enum SortOrder { export interface GraphFieldConfig extends LineConfig, FillConfig, PointsConfig, AxisConfig, BarConfig, StackableFieldConfig, HideableFieldConfig { drawStyle?: GraphDrawStyle; gradientMode?: GraphGradientMode; + insertNulls?: (boolean | number); thresholdsStyle?: GraphThresholdsStyleConfig; transform?: GraphTransform; } diff --git a/packages/grafana-schema/src/common/mudball.cue b/packages/grafana-schema/src/common/mudball.cue index d08fbeff1537e..a8fe6f5c291c6 100644 --- a/packages/grafana-schema/src/common/mudball.cue +++ b/packages/grafana-schema/src/common/mudball.cue @@ -224,6 +224,7 @@ GraphFieldConfig: { gradientMode?: GraphGradientMode thresholdsStyle?: GraphThresholdsStyleConfig transform?: GraphTransform + insertNulls?: bool | number } @cuetsy(kind="interface") // TODO docs From 9369f07e32298ad5868858e92dc288ed101a9863 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 30 Apr 2024 10:34:52 +0200 Subject: [PATCH 48/53] Alerting: Immutable plugin rules and alerting plugins extensions (#86042) * Add pluginsApi * Add rule origin badge * Make plugin provided rules read-only * Add plugin settings caching, add plugin icon on the rule detail page * Add basic extension point for custom plugin actions * Add support for alerting and recording rule extensions * Move plugin hooks to their own files * Add plugin custom actions to the alert list more actions menu * Add custom actions renderign test * Add more tests * Cleanup * Use test-utils in RuleViewer tests * Remove __grafana_origin label from the label autocomplete * Remove pluginsApi * Add plugin badge tooltip * Update tests * Add grafana origin constant key, remove unused code * Hide the grafana origin label * Fix typo, rename alerting extension points * Unify private labels handling * Add reactive plugins registry handling * Update tests * Fix tests * Fix tests * Fix panel tests * Add getRuleOrigin tests * Tests refactor, smalle improvements * Rename rule origin to better reflect the intent --------- Co-authored-by: Tom Ratcliffe --- .../src/types/pluginExtensions.ts | 2 + .../unified/components/AlertLabels.tsx | 6 +- .../components/alert-groups/GroupBy.tsx | 4 +- .../rule-editor/labels/LabelsField.tsx | 14 +- .../components/rule-viewer/Actions.tsx | 16 ++ .../rule-viewer/RuleViewer.test.tsx | 138 +++++++++++++----- .../components/rule-viewer/RuleViewer.tsx | 9 +- .../rule-viewer/__mocks__/server.ts | 52 +++---- .../components/rules/RuleActionsButtons.tsx | 21 ++- .../rules/RuleListGroupView.test.tsx | 7 +- .../components/rules/RulesTable.test.tsx | 6 + .../unified/components/rules/RulesTable.tsx | 12 +- .../alerting/unified/hooks/useAbilities.ts | 12 +- public/app/features/alerting/unified/mocks.ts | 14 ++ .../alerting/unified/mocks/plugins.ts | 17 +-- .../unified/plugins/PluginOriginBadge.tsx | 32 ++++ .../plugins/useRulePluginLinkExtensions.ts | 92 ++++++++++++ .../alerting/unified/testSetup/plugins.ts | 97 ++++++++++++ .../features/alerting/unified/utils/labels.ts | 8 + .../alerting/unified/utils/matchers.ts | 6 +- .../alerting/unified/utils/rules.test.ts | 45 ++++++ .../features/alerting/unified/utils/rules.ts | 38 +++++ .../PanelDataAlertingTab.test.tsx | 7 +- .../panel/alertlist/GroupByWithLoading.tsx | 5 +- public/app/plugins/panel/alertlist/util.ts | 4 - 25 files changed, 553 insertions(+), 111 deletions(-) create mode 100644 public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx create mode 100644 public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts create mode 100644 public/app/features/alerting/unified/testSetup/plugins.ts create mode 100644 public/app/features/alerting/unified/utils/rules.test.ts diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 85a123754f8e1..c9ed2b0506d29 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -117,6 +117,8 @@ export type PluginExtensionEventHelpers = { export enum PluginExtensionPoints { AlertInstanceAction = 'grafana/alerting/instance/action', AlertingHomePage = 'grafana/alerting/home', + AlertingAlertingRuleAction = 'grafana/alerting/alertingrule/action', + AlertingRecordingRuleAction = 'grafana/alerting/recordingrule/action', CommandPalette = 'grafana/commandpalette/action', DashboardPanelMenu = 'grafana/dashboard/panel/menu', DataSourceConfig = 'grafana/datasources/config', diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx index 30bfeb817b767..98a9102466bca 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -6,6 +6,8 @@ import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, getTagColorsFromName, useStyles2 } from '@grafana/ui'; +import { isPrivateLabel } from '../utils/labels'; + import { Label, LabelSize } from './Label'; interface Props { @@ -20,7 +22,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => { const labelsToShow = chain(labels) .toPairs() - .reject(isPrivateKey) + .reject(isPrivateLabel) .reject(([key]) => (showCommonLabels ? false : key in commonLabels)) .value(); @@ -63,8 +65,6 @@ function getLabelColor(input: string): string { return getTagColorsFromName(input).color; } -const isPrivateKey = ([key, _]: [string, string]) => key.startsWith('__') && key.endsWith('__'); - const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => ({ wrapper: css` display: flex; diff --git a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx index a35dba726d161..5cd7443478e67 100644 --- a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx @@ -5,6 +5,8 @@ import { SelectableValue } from '@grafana/data'; import { Icon, Label, MultiSelect } from '@grafana/ui'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import { isPrivateLabelKey } from '../../utils/labels'; + interface Props { groups: AlertmanagerGroup[]; groupBy: string[]; @@ -13,7 +15,7 @@ interface Props { export const GroupBy = ({ groups, groupBy, onGroupingChange }: Props) => { const labelKeyOptions = uniq(groups.flatMap((group) => group.alerts).flatMap(({ labels }) => Object.keys(labels))) - .filter((label) => !(label.startsWith('__') && label.endsWith('__'))) // Filter out private labels + .filter((label) => !isPrivateLabelKey(label)) // Filter out private labels .map((key) => ({ label: key, value: key, diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 685b1626c67c3..3573f37ef6c8a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -12,6 +12,7 @@ import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSel import { fetchRulerRulesIfNotFetchedYet } from '../../../state/actions'; import { SupportedPlugin } from '../../../types/pluginBridges'; import { RuleFormValues } from '../../../types/rule-form'; +import { isPrivateLabelKey } from '../../../utils/labels'; import AlertLabelDropdown from '../../AlertLabelDropdown'; import { AlertLabels } from '../../AlertLabels'; import { NeedHelpInfo } from '../NeedHelpInfo'; @@ -146,6 +147,9 @@ export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: Labels ); } + +const isKeyAllowed = (labelKey: string) => !isPrivateLabelKey(labelKey); + export function useCombinedLabels( dataSourceName: string, labelsPluginInstalled: boolean, @@ -169,12 +173,12 @@ export function useCombinedLabels( //------- Convert the keys from the ops labels to options for the dropdown const keysFromGopsLabels = useMemo(() => { - return mapLabelsToOptions(Object.keys(labelsByKeyOps), labelsInSubform); + return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform); }, [labelsByKeyOps, labelsInSubform]); //------- Convert the keys from the existing alerts to options for the dropdown const keysFromExistingAlerts = useMemo(() => { - return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts), labelsInSubform); + return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts).filter(isKeyAllowed), labelsInSubform); }, [labelsByKeyFromExisingAlerts, labelsInSubform]); // create two groups of labels, one for ops and one for custom @@ -238,6 +242,10 @@ export function useCombinedLabels( const getValuesForLabel = useCallback( (key: string) => { + if (!isKeyAllowed(key)) { + return []; + } + // values from existing alerts will take precedence over values from ops if (selectedKeyIsFromAlerts || !labelsPluginInstalled) { return mapLabelsToOptions(labelsByKeyFromExisingAlerts[key]); @@ -254,6 +262,7 @@ export function useCombinedLabels( getValuesForLabel, }; } + /* We will suggest labels from two sources: existing alerts and ops labels. We only will suggest labels from ops if the grafana-labels-app plugin is installed @@ -262,6 +271,7 @@ export function useCombinedLabels( export interface LabelsWithSuggestionsProps { dataSourceName: string; } + export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsProps) { const styles = useStyles2(getStyles); const { diff --git a/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx index ee57acb14b2cb..ff9d296d4170a 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx @@ -7,6 +7,7 @@ import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItem import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities'; +import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions'; import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; import { createUrl } from '../../utils/url'; @@ -22,6 +23,7 @@ interface Props { export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: Props) => { const { rule, identifier } = useAlertRule(); + const rulePluginLinkExtension = useRulePluginLinkExtension(rule); // check all abilities and permissions const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); @@ -71,6 +73,20 @@ export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: P childItems={[]} /> )} + {rulePluginLinkExtension.length > 0 && ( + <> + + {rulePluginLinkExtension.map((extension) => ( + + ))} + + )} {canDelete && ( <> diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 1a82e0561947f..65d5453fee0bb 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -1,16 +1,23 @@ -import { render, waitFor, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import React from 'react'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { render, waitFor, screen, userEvent } from 'test/test-utils'; import { byText, byRole } from 'testing-library-selector'; -import { setBackendSrv } from '@grafana/runtime'; +import { setBackendSrv, setPluginExtensionsHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; -import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks'; +import { + getCloudRule, + getGrafanaRule, + grantUserPermissions, + mockDataSource, + mockPluginLinkExtension, +} from '../../mocks'; +import { setupDataSources } from '../../testSetup/datasources'; +import { plugins, setupPlugins } from '../../testSetup/plugins'; import { Annotation } from '../../utils/constants'; +import { DataSourceType } from '../../utils/datasource'; import * as ruleId from '../../utils/rule-id'; import { AlertRuleProvider } from './RuleContext'; @@ -33,20 +40,58 @@ const ELEMENTS = { button: byRole('button', { name: /More/i }), actions: { silence: byRole('link', { name: /Silence/i }), - declareIncident: byRole('menuitem', { name: /Declare incident/i }), duplicate: byRole('menuitem', { name: /Duplicate/i }), copyLink: byRole('menuitem', { name: /Copy link/i }), export: byRole('menuitem', { name: /Export/i }), delete: byRole('menuitem', { name: /Delete/i }), }, + pluginActions: { + sloDashboard: byRole('link', { name: /SLO dashboard/i }), + declareIncident: byRole('link', { name: /Declare incident/i }), + assertsWorkbench: byRole('link', { name: /Open workbench/i }), + }, }, }, }; +const { apiHandlers: pluginApiHandlers } = setupPlugins(plugins.slo, plugins.incident, plugins.asserts); + +const server = createMockGrafanaServer(...pluginApiHandlers); + +setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'mimir-1' })); +setPluginExtensionsHook(() => ({ + extensions: [ + mockPluginLinkExtension({ pluginId: 'grafana-slo-app', title: 'SLO dashboard', path: '/a/grafana-slo-app' }), + mockPluginLinkExtension({ + pluginId: 'grafana-asserts-app', + title: 'Open workbench', + path: '/a/grafana-asserts-app', + }), + ], + isLoading: false, +})); + +beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingInstanceCreate, + ]); + setBackendSrv(backendSrv); +}); + +beforeEach(() => { + server.listen(); +}); + +afterAll(() => { + server.close(); +}); + describe('RuleViewer', () => { describe('Grafana managed alert rule', () => { - const server = createMockGrafanaServer(); - const mockRule = getGrafanaRule( { name: 'Test alert', @@ -71,29 +116,6 @@ describe('RuleViewer', () => { ); const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); - beforeAll(() => { - grantUserPermissions([ - AccessControlAction.AlertingRuleCreate, - AccessControlAction.AlertingRuleRead, - AccessControlAction.AlertingRuleUpdate, - AccessControlAction.AlertingRuleDelete, - AccessControlAction.AlertingInstanceCreate, - ]); - setBackendSrv(backendSrv); - }); - - beforeEach(() => { - server.listen(); - }); - - afterAll(() => { - server.close(); - }); - - afterEach(() => { - server.resetHandlers(); - }); - it('should render a Grafana managed alert rule', async () => { await renderRuleViewer(mockRule, mockRuleIdentifier); @@ -131,8 +153,12 @@ describe('RuleViewer', () => { }); }); - describe.skip('Data source managed alert rule', () => { - const mockRule = getCloudRule({ name: 'cloud test alert' }); + describe('Data source managed alert rule', () => { + const mockRule = getCloudRule({ + name: 'cloud test alert', + annotations: { [Annotation.summary]: 'cloud summary', [Annotation.runbookURL]: 'https://runbook.example.com' }, + group: { name: 'Cloud group', interval: '15m', rules: [], totals: { alerting: 1 } }, + }); const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule); beforeAll(() => { @@ -146,14 +172,53 @@ describe('RuleViewer', () => { renderRuleViewer(mockRule, mockRuleIdentifier); // assert on basic info to be vissible - expect(screen.getByText('Test alert')).toBeInTheDocument(); + expect(screen.getByText('cloud test alert')).toBeInTheDocument(); expect(screen.getByText('Firing')).toBeInTheDocument(); expect(screen.getByText(mockRule.annotations[Annotation.summary])).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'View panel' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: mockRule.annotations[Annotation.runbookURL] })).toBeInTheDocument(); expect(screen.getByText(`Every ${mockRule.group.interval}`)).toBeInTheDocument(); }); + + it('should render custom plugin actions for a plugin-provided rule', async () => { + const sloRule = getCloudRule({ + name: 'slo test alert', + labels: { __grafana_origin: 'plugin/grafana-slo-app' }, + }); + const sloRuleIdentifier = ruleId.fromCombinedRule('mimir-1', sloRule); + + const user = userEvent.setup(); + + renderRuleViewer(sloRule, sloRuleIdentifier); + + expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument(); + + await user.click(ELEMENTS.actions.more.button.get()); + + expect(ELEMENTS.actions.more.pluginActions.sloDashboard.get()).toBeInTheDocument(); + expect(ELEMENTS.actions.more.pluginActions.assertsWorkbench.query()).not.toBeInTheDocument(); + + await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled()); + }); + + it('should render different custom plugin actions for a different plugin-provided rule', async () => { + const assertsRule = getCloudRule({ + name: 'asserts test alert', + labels: { __grafana_origin: 'plugin/grafana-asserts-app' }, + }); + const assertsRuleIdentifier = ruleId.fromCombinedRule('mimir-1', assertsRule); + + renderRuleViewer(assertsRule, assertsRuleIdentifier); + + expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument(); + + await userEvent.click(ELEMENTS.actions.more.button.get()); + + expect(ELEMENTS.actions.more.pluginActions.assertsWorkbench.get()).toBeInTheDocument(); + expect(ELEMENTS.actions.more.pluginActions.sloDashboard.query()).not.toBeInTheDocument(); + + await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled()); + }); }); }); @@ -161,8 +226,7 @@ const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) render( - , - { wrapper: TestProvider } + ); await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index d6899597755be..ed317f4b98735 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -11,9 +11,12 @@ import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-aler import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { defaultPageNav } from '../../RuleViewer'; +import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { Annotation } from '../../utils/constants'; import { makeDashboardLink, makePanelLink } from '../../utils/misc'; import { + RulePluginOrigin, + getRulePluginOrigin, isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule, @@ -73,6 +76,7 @@ const RuleViewer = () => { const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule); const showError = hasError && !isPaused; + const ruleOrigin = getRulePluginOrigin(rule); const summary = annotations[Annotation.summary]; @@ -88,6 +92,7 @@ const RuleViewer = () => { state={isAlertType ? promRule.state : undefined} health={rule.promRule?.health} ruleType={rule.promRule?.type} + ruleOrigin={ruleOrigin} /> )} actions={actions} @@ -223,15 +228,17 @@ interface TitleProps { state?: PromAlertingRuleState; health?: RuleHealth; ruleType?: PromRuleType; + ruleOrigin?: RulePluginOrigin; } -export const Title = ({ name, paused = false, state, health, ruleType }: TitleProps) => { +export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => { const styles = useStyles2(getStyles); const isRecordingRule = ruleType === PromRuleType.Recording; return (
+ {ruleOrigin && } {name} diff --git a/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts index de4dcdcf2619a..4bfdda20e069a 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts +++ b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts @@ -1,11 +1,12 @@ -import { http, HttpResponse } from 'msw'; -import { SetupServer, setupServer } from 'msw/node'; +import { http, HttpResponse, RequestHandler } from 'msw'; +import { setupServer } from 'msw/node'; import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi'; -import { mockAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; +import { alertmanagerChoiceHandler } from '../../../mocks/alertmanagerApi'; + const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = { alertmanagersChoice: AlertmanagerChoice.Internal, numExternalAlertmanagers: 0, @@ -18,39 +19,24 @@ const folderAccess = { [AccessControlAction.AlertingRuleDelete]: true, }; -export function createMockGrafanaServer() { - const server = setupServer(); - - mockFolderAccess(server, folderAccess); - mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); - mockGrafanaIncidentPluginSettings(server); +export function createMockGrafanaServer(...handlers: RequestHandler[]) { + const folderHandler = mockFolderAccess(folderAccess); + const amChoiceHandler = alertmanagerChoiceHandler(alertmanagerChoiceMockedResponse); - return server; + return setupServer(folderHandler, amChoiceHandler, ...handlers); } // this endpoint is used to determine of we have edit / delete permissions for the Grafana managed alert rule // a user must alsso have permissions for the folder (namespace) in which the alert rule is stored -function mockFolderAccess(server: SetupServer, accessControl: Partial>) { - server.use( - http.get('/api/folders/:uid', ({ request }) => { - const url = new URL(request.url); - const uid = url.searchParams.get('uid'); - - return HttpResponse.json({ - title: 'My Folder', - uid, - accessControl, - }); - }) - ); - - return server; -} - -function mockGrafanaIncidentPluginSettings(server: SetupServer) { - server.use( - http.get('/api/plugins/grafana-incident-app/settings', () => { - return HttpResponse.json({}); - }) - ); +function mockFolderAccess(accessControl: Partial>) { + return http.get('/api/folders/:uid', ({ request }) => { + const url = new URL(request.url); + const uid = url.searchParams.get('uid'); + + return HttpResponse.json({ + title: 'My Folder', + uid, + accessControl, + }); + }); } diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index cf66b4b76b75a..484910ad4bb9b 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -24,6 +24,7 @@ import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities'; +import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions'; import { deleteRuleAction, fetchAllPromAndRulerRulesAction } from '../../state/actions'; import { getRulesSourceName } from '../../utils/datasource'; import { createShareLink, createViewLink } from '../../utils/misc'; @@ -59,6 +60,8 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => { const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + const ruleExtensionLinks = useRulePluginLinkExtension(rule); + const [editRuleSupported, editRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); const [deleteRuleSupported, deleteRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete); const [duplicateRuleSupported, duplicateRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate); @@ -178,10 +181,22 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => { /> ); } + } - if (canDeleteRule) { - moreActions.push( setRuleToDelete(rule)} />); - } + if (ruleExtensionLinks.length > 0) { + moreActions.push( + , + ...ruleExtensionLinks.map((extension) => ( + + )) + ); + } + + if (rulerRule && canDeleteRule) { + moreActions.push( + , + setRuleToDelete(rule)} /> + ); } if (buttons.length || moreActions.length) { diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx index 5e35dbdda9779..0c51d4ccbb9d2 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -4,7 +4,7 @@ import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { byRole } from 'testing-library-selector'; -import { locationService } from '@grafana/runtime'; +import { locationService, setPluginExtensionsHook } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; @@ -23,6 +23,11 @@ const ui = { cloudRulesHeading: byRole('heading', { name: 'Mimir / Cortex / Loki' }), }; +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + describe('RuleListGroupView', () => { describe('RBAC', () => { it('Should display Grafana rules when the user has the alert rule read permission', async () => { diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index 3065719d8c050..7f29dcc73ac22 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -5,6 +5,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { byRole } from 'testing-library-selector'; +import { setPluginExtensionsHook } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; import { CombinedRule } from 'app/types/unified-alerting'; @@ -19,6 +20,11 @@ const mocks = { useAlertRuleAbility: jest.mocked(useAlertRuleAbility), }; +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + const ui = { actionButtons: { edit: byRole('link', { name: 'Edit' }), diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 56985d845ef82..e1ae729a4f8da 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -16,8 +16,9 @@ import { CombinedRule } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; import { useHasRuler } from '../../hooks/useHasRuler'; +import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; import { Annotation } from '../../utils/constants'; -import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules'; +import { getRulePluginOrigin, isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; import { ProvisioningBadge } from '../Provisioning'; @@ -175,13 +176,18 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe size: showNextEvaluationColumn ? 4 : 5, }, { - id: 'provisioned', + id: 'metadata', label: '', // eslint-disable-next-line react/display-name renderCell: ({ data: rule }) => { const rulerRule = rule.rulerRule; - const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule); + const originMeta = getRulePluginOrigin(rule); + if (originMeta) { + return ; + } + + const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule); if (!isGrafanaManagedRule) { return null; } diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 9906553bf7b42..14ffa869fc5d5 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -9,7 +9,7 @@ import { alertmanagerApi } from '../api/alertmanagerApi'; import { useAlertmanager } from '../state/AlertmanagerContext'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -import { isFederatedRuleGroup, isGrafanaRulerRule } from '../utils/rules'; +import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules'; import { useIsRuleEditable } from './useIsRuleEditable'; @@ -147,9 +147,10 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities = { - [AlertRuleAction.Duplicate]: toAbility(MaybeSupported, rulesPermissions.create), + [AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create), [AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read), [AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false], [AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false], diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 7ac3c3dfb137f..fcbed3a177b56 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -10,6 +10,8 @@ import { DataSourceJsonData, DataSourcePluginMeta, DataSourceRef, + PluginExtensionLink, + PluginExtensionTypes, PluginMeta, PluginType, ScopedVars, @@ -707,6 +709,18 @@ export function getCloudRule(override?: Partial) { }); } +export function mockPluginLinkExtension(extension: Partial): PluginExtensionLink { + return { + type: PluginExtensionTypes.link, + id: 'plugin-id', + pluginId: 'grafana-test-app', + title: 'Test plugin link', + description: 'Test plugin link', + path: '/test', + ...extension, + }; +} + export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert { return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' }; } diff --git a/public/app/features/alerting/unified/mocks/plugins.ts b/public/app/features/alerting/unified/mocks/plugins.ts index 48c98edb33b5a..1a97fc39f5017 100644 --- a/public/app/features/alerting/unified/mocks/plugins.ts +++ b/public/app/features/alerting/unified/mocks/plugins.ts @@ -1,17 +1,10 @@ import { http, HttpResponse } from 'msw'; -import { SetupServer } from 'msw/lib/node'; import { PluginMeta } from '@grafana/data'; -import { SupportedPlugin } from '../types/pluginBridges'; - -export function mockPluginSettings(server: SetupServer, plugin: SupportedPlugin, response?: PluginMeta) { - server.use( - http.get(`/api/plugins/${plugin}/settings`, () => { - if (response) { - return HttpResponse.json(response); - } - return HttpResponse.json({}, { status: 404 }); - }) +export const pluginsHandler = (pluginsRegistry: Map) => + http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) => + pluginsRegistry.has(pluginId) + ? HttpResponse.json(pluginsRegistry.get(pluginId)!) + : HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 }) ); -} diff --git a/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx new file mode 100644 index 0000000000000..437d56fd1920c --- /dev/null +++ b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useAsync } from 'react-use'; + +import { Badge, Tooltip } from '@grafana/ui'; + +import { getPluginSettings } from '../../../plugins/pluginSettings'; + +interface PluginOriginBadgeProps { + pluginId: string; +} + +export function PluginOriginBadge({ pluginId }: PluginOriginBadgeProps) { + const { value: pluginMeta } = useAsync(() => getPluginSettings(pluginId)); + + const logo = pluginMeta?.info.logos?.small; + + const badgeIcon = logo ? ( + {pluginMeta?.name} + ) : ( + + ); + + const tooltipContent = pluginMeta + ? `This rule is managed by the ${pluginMeta?.name} plugin` + : `This rule is managed by a plugin`; + + return ( + +
{badgeIcon}
+
+ ); +} diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts new file mode 100644 index 0000000000000..237dbbe8154c2 --- /dev/null +++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; + +import { PluginExtensionPoints } from '@grafana/data'; +import { usePluginLinkExtensions } from '@grafana/runtime'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { PromRuleType } from 'app/types/unified-alerting-dto'; + +import { getRulePluginOrigin } from '../utils/rules'; + +interface BaseRuleExtensionContext { + name: string; + namespace: string; + group: string; + expression: string; + labels: Record; +} + +export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext { + annotations: Record; +} + +export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {} + +export function useRulePluginLinkExtension(rule: CombinedRule) { + const ruleExtensionPoint = useRuleExtensionPoint(rule); + const { extensions } = usePluginLinkExtensions(ruleExtensionPoint); + + const ruleOrigin = getRulePluginOrigin(rule); + const ruleType = rule.promRule?.type; + if (!ruleOrigin || !ruleType) { + return []; + } + + const { pluginId } = ruleOrigin; + + return extensions.filter((extension) => extension.pluginId === pluginId); +} + +export interface PluginRuleExtensionParam { + pluginId: string; + rule: CombinedRule; +} + +interface AlertingRuleExtensionPoint { + extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction; + context: AlertingRuleExtensionContext; +} + +interface RecordingRuleExtensionPoint { + extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction; + context: RecordingRuleExtensionContext; +} + +interface EmptyExtensionPoint { + extensionPointId: ''; +} + +type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint; + +function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint { + return useMemo(() => { + const ruleType = rule.promRule?.type; + + switch (ruleType) { + case PromRuleType.Alerting: + return { + extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction, + context: { + name: rule.name, + namespace: rule.namespace.name, + group: rule.group.name, + expression: rule.query, + labels: rule.labels, + annotations: rule.annotations, + }, + }; + case PromRuleType.Recording: + return { + extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction, + context: { + name: rule.name, + namespace: rule.namespace.name, + group: rule.group.name, + expression: rule.query, + labels: rule.labels, + }, + }; + default: + return { extensionPointId: '' }; + } + }, [rule]); +} diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts new file mode 100644 index 0000000000000..1ef1b4fae8750 --- /dev/null +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -0,0 +1,97 @@ +import { RequestHandler } from 'msw'; + +import { PluginMeta, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { pluginsHandler } from '../mocks/plugins'; + +export function setupPlugins(...plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } { + const pluginsRegistry = new Map(); + plugins.forEach((plugin) => pluginsRegistry.set(plugin.id, plugin)); + + pluginsRegistry.forEach((plugin) => { + config.apps[plugin.id] = { + id: plugin.id, + path: plugin.baseUrl, + preload: true, + version: plugin.info.version, + angular: plugin.angular ?? { detected: false, hideDeprecation: false }, + }; + }); + + return { + apiHandlers: [pluginsHandler(pluginsRegistry)], + }; +} + +export const plugins: Record = { + slo: { + id: 'grafana-slo-app', + name: 'SLO dashboard', + type: PluginType.app, + enabled: true, + info: { + author: { + name: 'Grafana Labs', + url: '', + }, + description: 'Create and manage Service Level Objectives', + links: [], + logos: { + small: 'public/plugins/grafana-slo-app/img/logo.svg', + large: 'public/plugins/grafana-slo-app/img/logo.svg', + }, + screenshots: [], + version: 'local-dev', + updated: '2024-04-09', + }, + module: 'public/plugins/grafana-slo-app/module.js', + baseUrl: 'public/plugins/grafana-slo-app', + }, + incident: { + id: 'grafana-incident-app', + name: 'Incident management', + type: PluginType.app, + enabled: true, + info: { + author: { + name: 'Grafana Labs', + url: '', + }, + description: 'Incident management', + links: [], + logos: { + small: 'public/plugins/grafana-incident-app/img/logo.svg', + large: 'public/plugins/grafana-incident-app/img/logo.svg', + }, + screenshots: [], + version: 'local-dev', + updated: '2024-04-09', + }, + module: 'public/plugins/grafana-incident-app/module.js', + baseUrl: 'public/plugins/grafana-incident-app', + }, + asserts: { + id: 'grafana-asserts-app', + name: 'Asserts', + type: PluginType.app, + enabled: true, + info: { + author: { + name: 'Grafana Labs', + url: '', + }, + description: 'Asserts', + links: [], + logos: { + small: 'public/plugins/grafana-asserts-app/img/logo.svg', + large: 'public/plugins/grafana-asserts-app/img/logo.svg', + }, + screenshots: [], + version: 'local-dev', + updated: '2024-04-09', + }, + module: 'public/plugins/grafana-asserts-app/module.js', + baseUrl: 'public/plugins/grafana-asserts-app', + }, +}; diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index 49da17ab97ad7..7c89af0ea1398 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -32,3 +32,11 @@ export function arrayKeyValuesToObject( return labelsObject; } + +export const GRAFANA_ORIGIN_LABEL = '__grafana_origin'; + +export function isPrivateLabelKey(labelKey: string) { + return (labelKey.startsWith('__') && labelKey.endsWith('__')) || labelKey === GRAFANA_ORIGIN_LABEL; +} + +export const isPrivateLabel = ([key, _]: [string, string]) => isPrivateLabelKey(key); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index 2cac197f8d914..a596a318da055 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -11,6 +11,8 @@ import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/data import { Labels } from '../../../../types/unified-alerting-dto'; +import { isPrivateLabelKey } from './labels'; + const matcherOperators = [ MatcherOperator.regex, MatcherOperator.notRegex, @@ -91,9 +93,7 @@ export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { } export const getMatcherQueryParams = (labels: Labels) => { - const validMatcherLabels = Object.entries(labels).filter( - ([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__')) - ); + const validMatcherLabels = Object.entries(labels).filter(([labelKey]) => !isPrivateLabelKey(labelKey)); const matcherUrlParams = new URLSearchParams(); validMatcherLabels.forEach(([labelKey, labelValue]) => diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts new file mode 100644 index 0000000000000..7ec2349f29843 --- /dev/null +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -0,0 +1,45 @@ +import { config } from '@grafana/runtime'; + +import { mockCombinedRule } from '../mocks'; + +import { GRAFANA_ORIGIN_LABEL } from './labels'; +import { getRulePluginOrigin } from './rules'; + +describe('getRuleOrigin', () => { + it('returns undefined when no origin label is present', () => { + const rule = mockCombinedRule({ + labels: {}, + }); + expect(getRulePluginOrigin(rule)).toBeUndefined(); + }); + + it('returns undefined when origin label does not match expected format', () => { + const rule = mockCombinedRule({ + labels: { [GRAFANA_ORIGIN_LABEL]: 'invalid_format' }, + }); + expect(getRulePluginOrigin(rule)).toBeUndefined(); + }); + + it('returns undefined when plugin is not installed', () => { + const rule = mockCombinedRule({ + labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/uninstalled_plugin' }, + }); + expect(getRulePluginOrigin(rule)).toBeUndefined(); + }); + + it('returns pluginId when origin label matches expected format and plugin is installed', () => { + config.apps = { + installed_plugin: { + id: 'installed_plugin', + version: '', + path: '', + preload: true, + angular: { detected: false, hideDeprecation: false }, + }, + }; + const rule = mockCombinedRule({ + labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/installed_plugin' }, + }); + expect(getRulePluginOrigin(rule)).toEqual({ pluginId: 'installed_plugin' }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 49481acb9713a..248774818e2dd 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -1,10 +1,12 @@ import { capitalize } from 'lodash'; import { AlertState } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, AlertingRule, CloudRuleIdentifier, + CombinedRule, CombinedRuleGroup, CombinedRuleWithLocation, GrafanaRuleIdentifier, @@ -33,6 +35,7 @@ import { RuleHealth } from '../search/rulesSearchParser'; import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { getRulesSourceName } from './datasource'; +import { GRAFANA_ORIGIN_LABEL } from './labels'; import { AsyncRequestState } from './redux'; import { safeParsePrometheusDuration } from './time'; @@ -100,6 +103,41 @@ export function getRuleHealth(health: string): RuleHealth | undefined { } } +export interface RulePluginOrigin { + pluginId: string; +} + +export function getRulePluginOrigin(rule: CombinedRule): RulePluginOrigin | undefined { + // com.grafana.origin=plugin/ + // Prom and Mimir do not support dots in label names 😔 + const origin = rule.labels[GRAFANA_ORIGIN_LABEL]; + if (!origin) { + return undefined; + } + + const match = origin.match(/^plugin\/(?.+)$/); + if (!match?.groups) { + return undefined; + } + + const pluginId = match.groups['pluginId']; + const pluginInstalled = isPluginInstalled(pluginId); + + if (!pluginInstalled) { + return undefined; + } + + return { pluginId }; +} + +function isPluginInstalled(pluginId: string) { + return Boolean(config.apps[pluginId]); +} + +export function isPluginProvidedRule(rule: CombinedRule): boolean { + return Boolean(getRulePluginOrigin(rule)); +} + export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): string { if (state === PromAlertingRuleState.Inactive) { return 'Normal'; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index 06320963aa8d1..ea559badb2a09 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -6,7 +6,7 @@ import { byTestId } from 'testing-library-selector'; import { DataSourceApi } from '@grafana/data'; import { PromOptions, PrometheusDatasource } from '@grafana/prometheus'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { locationService, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { fetchRules } from 'app/features/alerting/unified/api/prometheus'; import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler'; @@ -48,6 +48,11 @@ jest.mock('app/features/alerting/unified/api/ruler'); jest.spyOn(config, 'getAllDataSources'); jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + const dataSources = { prometheus: mockDataSource({ name: 'Prometheus', diff --git a/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx b/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx index ed5713720f7bb..dcf90a7f5b0b4 100644 --- a/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx +++ b/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx @@ -14,8 +14,7 @@ import { AlertingRule } from 'app/types/unified-alerting'; import { PromRuleType } from 'app/types/unified-alerting-dto'; import { fetchPromRulesAction } from '../../../features/alerting/unified/state/actions'; - -import { isPrivateLabel } from './util'; +import { isPrivateLabelKey } from '../../../features/alerting/unified/utils/labels'; interface Props { id: string; @@ -56,7 +55,7 @@ export const GroupBy = (props: Props) => { .flatMap((group) => group.rules.filter((rule): rule is AlertingRule => rule.type === PromRuleType.Alerting)) .flatMap((rule) => rule.alerts ?? []) .map((alert) => Object.keys(alert.labels ?? {})) - .flatMap((labels) => labels.filter(isPrivateLabel)); + .flatMap((labels) => labels.filter((label) => !isPrivateLabelKey(label))); return uniq(allLabels); }, [allRequestsReady, promRulesByDatasource]); diff --git a/public/app/plugins/panel/alertlist/util.ts b/public/app/plugins/panel/alertlist/util.ts index 47479c7004ed0..5acf905d27c28 100644 --- a/public/app/plugins/panel/alertlist/util.ts +++ b/public/app/plugins/panel/alertlist/util.ts @@ -36,7 +36,3 @@ export function filterAlerts( ); }); } - -export function isPrivateLabel(label: string) { - return !(label.startsWith('__') && label.endsWith('__')); -} From 6dbc44920c4034f928c68479965f4e202d5ba70f Mon Sep 17 00:00:00 2001 From: Tim Mulqueen Date: Tue, 30 Apr 2024 09:54:25 +0100 Subject: [PATCH 49/53] Dashboard Scene - Variable Fix: cancel out margin-bottom of placeholder in loading state (#87107) fix: cancel out margin-bottom of placeholder in loading state --- .../settings/variables/VariableEditorForm.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index d04e228acc46e..e94b5b0904477 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -152,7 +152,11 @@ export function VariableEditorForm({ data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton} onClick={onRunQuery} > - {runQueryState.loading ? : `Run query`} + {runQueryState.loading ? ( + + ) : ( + `Run query` + )} )} @@ -165,4 +169,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ buttonContainer: css({ marginTop: theme.spacing(2), }), + loadingPlaceHolder: css({ + marginBottom: 0, + }), }); From 9203f84bc83a9f710749c47a20130ebd3a63f337 Mon Sep 17 00:00:00 2001 From: antonio <45235678+tonypowa@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:06:25 +0200 Subject: [PATCH 50/53] docs / alerting / fundamentals / templates (#86983) * docs / alerting / fundamentals / templates * renamed and adjusted front matter * pretty * frontmatter * admonition fix * admo * restructuring * Update docs/sources/alerting/fundamentals/notifications/templates.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/fundamentals/notifications/templates.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/fundamentals/notifications/templates.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/fundamentals/notifications/templates.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/fundamentals/notifications/templates.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * amended admonition --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --- .../notifications/message-templating.md | 51 ------ .../fundamentals/notifications/templates.md | 159 ++++++++++++++++++ 2 files changed, 159 insertions(+), 51 deletions(-) delete mode 100644 docs/sources/alerting/fundamentals/notifications/message-templating.md create mode 100644 docs/sources/alerting/fundamentals/notifications/templates.md diff --git a/docs/sources/alerting/fundamentals/notifications/message-templating.md b/docs/sources/alerting/fundamentals/notifications/message-templating.md deleted file mode 100644 index 6f93872912991..0000000000000 --- a/docs/sources/alerting/fundamentals/notifications/message-templating.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -aliases: - - ../../contact-points/message-templating/ # /docs/grafana//alerting/contact-points/message-templating/ - - ../../alert-rules/message-templating/ # /docs/grafana//alerting/alert-rules/message-templating/ - - ../../unified-alerting/message-templating/ # /docs/grafana//alerting/unified-alerting/message-templating/ -canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/message-templating/ -description: Learn about templates -keywords: - - grafana - - alerting - - guide - - contact point - - templating -labels: - products: - - cloud - - enterprise - - oss -title: Templates -weight: 114 ---- - -## Templates - -Use templating to customize, format, and reuse alert notification messages. Create more flexible and informative alert notification messages by incorporating dynamic content, such as metric values, labels, and other contextual information. - -In Grafana, there are two ways to template your alert notification messages: - -1. Labels and annotations - - - Template labels and annotations in alert rules. - - Labels and annotations contain information about an alert. - - Labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. - -2. Notification templates - - - Template notifications in contact points. - - Add notification templates to contact points for reuse and consistent messaging in your notifications. - - Use notification templates to change the title, message, and format of the message in your notifications. - -This diagram illustrates the entire process of templating, from the creation of labels and annotations in alert rules or notification templates in contact points, to what they look like when exported and applied in your alert notification messages. - -{{< figure src="/media/docs/alerting/grafana-templating-diagram-2.jpg" max-width="1200px" caption="How Templating works" >}} - -In this diagram: - -- **Monitored Application**: A web server, database, or any other service generating metrics. For example, it could be an NGINX server providing metrics about request rates, response times, and so on. -- **Prometheus**: Prometheus collects metrics from the monitored application. For example, it might scrape metrics from the NGINX server, including labels like instance (the server hostname) and job (the service name). -- **Grafana**: Grafana queries Prometheus to retrieve metrics data. For example, you might create an alert rule to monitor NGINX request rates over time, and template labels or annotations based on the instance label. -- **Alertmanager**: Part of the Prometheus ecosystem, Alertmanager handles alert notifications. For example, if the request rate exceeds a certain threshold on a particular NGINX server, Alertmanager can send an alert notification to, for example, Slack or email, including the server name and the exceeded threshold (the instance label will be interpolated, and the actual server name will appear in the alert notification). -- **Alert notification**: When an alert rule condition is met, Alertmanager sends a notification to various channels such as Slack, Grafana OnCall, etc. These notifications can include information from the labels associated with the alerting rule. For example, if an alert triggers due to high CPU usage on a specific server, the notification message can include details like server name (instance label), disk usage percentage, and the threshold that was exceeded. diff --git a/docs/sources/alerting/fundamentals/notifications/templates.md b/docs/sources/alerting/fundamentals/notifications/templates.md new file mode 100644 index 0000000000000..dacd9a5310ff3 --- /dev/null +++ b/docs/sources/alerting/fundamentals/notifications/templates.md @@ -0,0 +1,159 @@ +--- +aliases: + - ../../contact-points/message-templating/ # /docs/grafana//alerting/contact-points/message-templating/ + - ../../alert-rules/message-templating/ # /docs/grafana//alerting/alert-rules/message-templating/ + - ../../unified-alerting/message-templating/ # /docs/grafana//alerting/unified-alerting/message-templating/ +canonical: https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/templates/ +description: Learn about templates +keywords: + - grafana + - alerting + - guide + - contact point + - templating +labels: + products: + - cloud + - enterprise + - oss +title: Templates +weight: 114 +--- + +# Templates + +Use templating to customize, format, and reuse alert notification messages. Create more flexible and informative alert notification messages by incorporating dynamic content, such as metric values, labels, and other contextual information. + +In Grafana, there are two ways to template your alert notification messages: + +1. Labels and annotations + + - Template labels and annotations in alert rules. + - Labels and annotations contain information about an alert. + - Labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. + +2. Notification templates + + - Template notifications in contact points. + - Add notification templates to contact points for reuse and consistent messaging in your notifications. + - Use notification templates to change the title, message, and format of the message in your notifications. + +This diagram illustrates the entire process of templating, from the creation of labels and annotations in alert rules or notification templates in contact points, to what they look like when exported and applied in your alert notification messages. + +{{< figure src="/media/docs/alerting/grafana-templating-diagram-2.jpg" max-width="1200px" caption="How Templating works" >}} + +In this diagram: + +- **Monitored Application**: A web server, database, or any other service generating metrics. For example, it could be an NGINX server providing metrics about request rates, response times, and so on. +- **Prometheus**: Prometheus collects metrics from the monitored application. For example, it might scrape metrics from the NGINX server, including labels like instance (the server hostname) and job (the service name). +- **Grafana**: Grafana queries Prometheus to retrieve metrics data. For example, you might create an alert rule to monitor NGINX request rates over time, and template labels or annotations based on the instance label. +- **Alertmanager**: Part of the Prometheus ecosystem, Alertmanager handles alert notifications. For example, if the request rate exceeds a certain threshold on a particular NGINX server, Alertmanager can send an alert notification to, for example, Slack or email, including the server name and the exceeded threshold (the instance label will be interpolated, and the actual server name will appear in the alert notification). +- **Alert notification**: When an alert rule condition is met, Alertmanager sends a notification to various channels such as Slack, Grafana OnCall, etc. These notifications can include information from the labels associated with the alerting rule. For example, if an alert triggers due to high CPU usage on a specific server, the notification message can include details like server name (instance label), disk usage percentage, and the threshold that was exceeded. + +## Labels and annotations + +Labels and annotations contain information about an alert. Labels are used to differentiate an alert from all other alerts, while annotations are used to add additional information to an existing alert. + +### Template labels + +Label templates are applied in the alert rule itself (i.e. in the Configure labels and notifications section of an alert). + +{{}} +Think about templating labels when you need to improve or change how alerts are uniquely identified. This is especially helpful if the labels you get from your query aren't detailed enough. Keep in mind that it's better to keep long sentences for summaries and descriptions. Also, avoid using the query's value in labels because it may result in the creation of many alerts when you actually only need one. +{{}} + +Templating can be applied by using variables and functions. These variables can represent dynamic values retrieved from your data queries. + +{{}} +In Grafana templating, the $ and . symbols are used to reference variables and their properties. You can reference variables directly in your alert rule definitions using the $ symbol followed by the variable name. Similarly, you can access properties of variables using the dot (.) notation within alert rule definitions. +{{}} + +Here are some commonly used built-in [variables][variables-label-annotation] to interact with the name and value of labels in Grafana alerting: + +- The `$labels` variable, which contains all labels from the query. + + For example, let's say you have an alert rule that triggers when the CPU usage exceeds a certain threshold. You want to create annotations that provide additional context when this alert is triggered, such as including the specific server that experienced the high CPU usage. + + The host {{ index $labels "instance" }} has exceeded 80% CPU usage for the last 5 minutes + + The outcome of this template would print: + + The host instance 1 has exceeded 80% CPU usage for the last 5 minutes + +- The `$value` variable, which is a string containing the labels and values of all instant queries; threshold, reduce and math expressions, and classic conditions in the alert rule. + + In the context of the previous example, $value variable would write something like this: + + CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ $value }} + + The outcome of this template would print: + + CPU usage for instance1 has exceeded 80% for the last 5 minutes: [ var='A' labels={instance=instance1} value=81.234 ] + +- The `$values` variable is a table containing the labels and floating point values of all instant queries and expressions, indexed by their Ref IDs (i.e. the id that identifies the query or expression. By default the Red ID of the query is “A”). + + Given an alert with the labels instance=server1 and an instant query with the value 81.2345, would write like this: + + CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes: {{ index $values "A" }} + + And it would print: + + CPU usage for instance1 has exceeded 80% for the last 5 minutes: 81.2345 + +### Template annotations + +Both labels and annotations have the same structure: a set of named values; however their intended uses are different. The purpose of annotations is to add additional information to existing alerts. + +There are a number of suggested annotations in Grafana such as `description`, `summary`, `runbook_url`, `dashboardUId` and `panelId`. Like labels, annotations must have a name, and their value can contain a combination of text and template code that is evaluated when an alert is fired. + +Here is an example of templating an annotation in the context of an alert rule. The text/template is added into the Add annotations section. + + CPU usage for {{ index $labels "instance" }} has exceeded 80% for the last 5 minutes + +The outcome of this template would print + + CPU usage for Instance 1 has exceeded 80% for the last 5 minutes + +### Template notifications + +Notification templates represent the alternative approach to templating designed for reusing templates. Notifications are messages to inform users about events or conditions triggered by alerts. You can create reusable notification templates to customize the content and format of alert notifications. Variables, labels, or other context-specific details can be added to the templates to dynamically insert information like metric values. + +Here is an example of a notification template: + +```go +{ define "alerts.message" -}} +{{ if .Alerts.Firing -}} +{{ len .Alerts.Firing }} firing alert(s) +{{ template "alerts.summarize" .Alerts.Firing }} +{{- end }} +{{- if .Alerts.Resolved -}} +{{ len .Alerts.Resolved }} resolved alert(s) +{{ template "alerts.summarize" .Alerts.Resolved }} +{{- end }} +{{- end }} + +{{ define "alerts.summarize" -}} +{{ range . -}} +- {{ index .Annotations "summary" }} +{{ end }} +{{ end }} +``` + +This is the message you would receive in your contact point: + + 1 firing alert(s) + - The database server db1 has exceeded 75% of available disk space. Disk space used is 76%, please resize the disk size within the next 24 hours + + 1 resolved alert(s) + - The web server web1 has been responding to 5% of HTTP requests with 5xx errors for the last 5 minutes + +Once the template is created, you need to make reference to it in your **Contact point** (in the Optional [contact point] settings) . + +{{}} +It's not recommended to include individual alert information within notification templates. Instead, it's more effective to incorporate such details within the rule using labels and annotations. +{{}} + +{{% docs/reference %}} +[variables-label-annotation]: "/docs/grafana/ -> /docs/grafana//alerting/alerting-rules/templating-labels-annotations" +[variables-label-annotation]: "/docs/grafana-cloud/ -> /docs/grafana-cloud/alerting-and-irm/alerting/alerting-rules/templating-labels-annotations" +{{% /docs/reference %}} From ec6f59a678ba97d43353a2bcfda1c9b241c55e31 Mon Sep 17 00:00:00 2001 From: Arati R <33031346+suntala@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:14:33 +0200 Subject: [PATCH 51/53] Chore: Update protoc-gen-go (#87116) Update protoc-gen-go --- pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go | 4 ++-- .../backendplugin/pluginextensionv2/rendererv2_grpc.pb.go | 2 +- pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go | 4 ++-- .../backendplugin/pluginextensionv2/sanitizer_grpc.pb.go | 2 +- .../backendplugin/secretsmanagerplugin/secretsmanager.pb.go | 4 ++-- .../secretsmanagerplugin/secretsmanager_grpc.pb.go | 2 +- pkg/services/store/entity/entity.pb.go | 4 ++-- pkg/services/store/entity/entity_grpc.pb.go | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go index 4f235a4f61dbb..ab2a9b8e673d3 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 -// protoc v4.25.2 +// protoc-gen-go v1.33.0 +// protoc v5.26.1 // source: rendererv2.proto package pluginextensionv2 diff --git a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go index 7625f9655e2ee..5875a69e090c7 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/rendererv2_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.2 +// - protoc v5.26.1 // source: rendererv2.proto package pluginextensionv2 diff --git a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go index a420647a68788..b464bbdc65c7c 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 -// protoc v4.25.2 +// protoc-gen-go v1.33.0 +// protoc v5.26.1 // source: sanitizer.proto package pluginextensionv2 diff --git a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go index 80a3f117df3dc..a56ffbffc8277 100644 --- a/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go +++ b/pkg/plugins/backendplugin/pluginextensionv2/sanitizer_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.2 +// - protoc v5.26.1 // source: sanitizer.proto package pluginextensionv2 diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go index 777de3c42f24f..09afbab365425 100644 --- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 -// protoc v4.25.2 +// protoc-gen-go v1.33.0 +// protoc v5.26.1 // source: secretsmanager.proto package secretsmanagerplugin diff --git a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go index 264020b216e8a..945d6fa9f7f04 100644 --- a/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go +++ b/pkg/plugins/backendplugin/secretsmanagerplugin/secretsmanager_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.2 +// - protoc v5.26.1 // source: secretsmanager.proto package secretsmanagerplugin diff --git a/pkg/services/store/entity/entity.pb.go b/pkg/services/store/entity/entity.pb.go index 76a04593bd1cb..1674b8d7fdcb4 100644 --- a/pkg/services/store/entity/entity.pb.go +++ b/pkg/services/store/entity/entity.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 -// protoc v4.25.2 +// protoc-gen-go v1.33.0 +// protoc v5.26.1 // source: entity.proto package entity diff --git a/pkg/services/store/entity/entity_grpc.pb.go b/pkg/services/store/entity/entity_grpc.pb.go index e76302c91dcd1..c1ab389ca2d3d 100644 --- a/pkg/services/store/entity/entity_grpc.pb.go +++ b/pkg/services/store/entity/entity_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.2 +// - protoc v5.26.1 // source: entity.proto package entity From 7f1b2ef20545f2fa6aa40f87ab69330c8a53d625 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 30 Apr 2024 13:04:58 +0200 Subject: [PATCH 52/53] Select: Add data-testid to Input (#87105) * Select: Add custom input component * Forward data-testid * Add input selector * Props check --- e2e/various-suite/loki-query-builder.spec.ts | 4 ++-- .../src/selectors/components.ts | 1 + .../src/components/Select/CustomInput.tsx | 15 +++++++++++++++ .../src/components/Select/SelectBase.tsx | 2 ++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 packages/grafana-ui/src/components/Select/CustomInput.tsx diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts index 1707a32dd859f..45b527fca5b94 100644 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ b/e2e/various-suite/loki-query-builder.spec.ts @@ -72,9 +72,9 @@ describe('Loki query builder', () => { // Add labels to remove error e2e.components.QueryBuilder.labelSelect().should('be.visible').click(); // wait until labels are loaded and set on the component before starting to type - e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('i'); + e2e.components.QueryBuilder.inputSelect().type('i'); cy.wait('@labelsRequest'); - e2e.components.QueryBuilder.labelSelect().children('div').children('input').type('nstance{enter}'); + e2e.components.QueryBuilder.inputSelect().type('nstance{enter}'); e2e.components.QueryBuilder.matchOperatorSelect() .should('be.visible') .click({ force: true }) diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index a2142e464fbad..9d73f68f3ff0d 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -452,6 +452,7 @@ export const Components = { QueryBuilder: { queryPatterns: 'data-testid Query patterns', labelSelect: 'data-testid Select label', + inputSelect: 'data-testid Select label-input', valueSelect: 'data-testid Select value', matchOperatorSelect: 'data-testid Select match operator', }, diff --git a/packages/grafana-ui/src/components/Select/CustomInput.tsx b/packages/grafana-ui/src/components/Select/CustomInput.tsx new file mode 100644 index 0000000000000..5531b314ec2be --- /dev/null +++ b/packages/grafana-ui/src/components/Select/CustomInput.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { components, InputProps } from 'react-select'; + +/** + * Custom input component for react-select to add data-testid attribute + */ +export const CustomInput = (props: InputProps) => { + let testId; + + if ('data-testid' in props.selectProps && props.selectProps['data-testid']) { + testId = props.selectProps['data-testid'] + '-input'; + } + + return ; +}; diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index 487f1a118ea60..a762fd6a9a468 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -11,6 +11,7 @@ import { useTheme2 } from '../../themes'; import { Icon } from '../Icon/Icon'; import { Spinner } from '../Spinner/Spinner'; +import { CustomInput } from './CustomInput'; import { DropdownIndicator } from './DropdownIndicator'; import { IndicatorsContainer } from './IndicatorsContainer'; import { InputControl } from './InputControl'; @@ -330,6 +331,7 @@ export function SelectBase({ SelectContainer, MultiValueContainer: MultiValueContainer, MultiValueRemove: !disabled ? MultiValueRemove : () => null, + Input: CustomInput, ...components, }} styles={selectStyles} From a2cba3d0b5ae4f1675f5c16f48a1b4f3a250724f Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Tue, 30 Apr 2024 13:15:56 +0200 Subject: [PATCH 53/53] User: Add tracing (#87028) * Inject tracer in tests * Annotate with traces Co-authored-by: Gabriel MABILLE --- pkg/api/folder_bench_test.go | 5 +- pkg/api/org_users_test.go | 6 +- pkg/api/user_test.go | 21 ++- .../commands/conflict_user_command.go | 37 +++-- .../accesscontrol/database/database_test.go | 25 +-- .../resourcepermissions/api_test.go | 37 ++--- .../resourcepermissions/service_test.go | 36 +++-- .../resourcepermissions/store_bench_test.go | 4 +- .../resourcepermissions/store_test.go | 6 +- .../database/database_folder_test.go | 42 +++-- .../libraryelements/libraryelements_test.go | 8 +- .../librarypanels/librarypanels_test.go | 5 +- pkg/services/org/orgimpl/store_test.go | 6 +- .../queryhistory/queryhistory_test.go | 6 +- pkg/services/quota/quotaimpl/quota_test.go | 5 +- .../serviceaccounts/database/store_test.go | 6 +- pkg/services/serviceaccounts/tests/common.go | 11 +- pkg/services/stats/statsimpl/stats_test.go | 5 +- pkg/services/team/teamimpl/store_test.go | 16 +- pkg/services/user/userimpl/store_test.go | 16 +- pkg/services/user/userimpl/user.go | 153 ++++++++++++------ pkg/services/user/userimpl/user_test.go | 62 +++---- .../api/alerting/api_alertmanager_test.go | 6 +- pkg/tests/api/correlations/common_test.go | 6 +- .../api/dashboards/api_dashboards_test.go | 6 +- pkg/tests/api/folders/api_folder_test.go | 6 +- pkg/tests/api/plugins/api_plugins_test.go | 6 +- pkg/tests/api/stats/admin_test.go | 6 +- pkg/tests/apis/helper.go | 5 +- pkg/tests/testinfra/testinfra.go | 5 +- pkg/tests/utils.go | 6 +- 31 files changed, 368 insertions(+), 202 deletions(-) diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 19db1a0932293..7ff5c315ea81c 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -214,7 +214,10 @@ func setupDB(b testing.TB) benchScenario { require.NoError(b, err) cache := localcache.ProvideService() - userSvc, err := userimpl.ProvideService(db, orgService, cfg, teamSvc, cache, "atest.FakeQuotaService{}, bundleregistry.ProvideService()) + userSvc, err := userimpl.ProvideService( + db, orgService, cfg, teamSvc, cache, tracing.InitializeTracerForTest(), + "atest.FakeQuotaService{}, bundleregistry.ProvideService(), + ) require.NoError(b, err) var orgID int64 = 1 diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 94dd640736baf..f3a3834b22f8b 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" "github.com/grafana/grafana/pkg/models/roletype" @@ -43,7 +44,10 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore db.DB, cfg *setting.Cfg) { quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) id, err := orgService.GetOrCreate(context.Background(), "testOrg") diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 5bd8bbd2b6809..be9f4f129a2fc 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db/dbtest" "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/login/social/socialtest" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -80,7 +81,10 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { hs.authInfoService = srv orgSvc, err := orgimpl.ProvideService(sqlStore, settings, quotatest.New(false, nil)) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, sc.cfg, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) hs.userService = userSvc @@ -150,7 +154,10 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { } orgSvc, err := orgimpl.ProvideService(sqlStore, sc.cfg, quotatest.New(false, nil)) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, sc.cfg, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) _, err = userSvc.Create(context.Background(), &createUserCmd) require.Nil(t, err) @@ -384,7 +391,10 @@ func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPSer tempUserService := tempuserimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotatest.New(false, nil)) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) // Create test user @@ -610,7 +620,10 @@ func TestUser_UpdateEmail(t *testing.T) { tempUserSvc := tempuserimpl.ProvideService(sqlStore, settings) orgSvc, err := orgimpl.ProvideService(sqlStore, settings, quotatest.New(false, nil)) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, settings, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, settings, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) server := SetupAPITestServer(t, func(hs *HTTPServer) { diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index faa8455867b63..f9d592bb2b3a5 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -33,7 +33,7 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, featuremgmt.FeatureToggles, error) { +func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, tracing.Tracer, featuremgmt.FeatureToggles, error) { configOptions := strings.Split(cmd.String("configOverrides"), " ") configOptions = append(configOptions, cmd.Args().Slice()...) cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ @@ -43,19 +43,33 @@ func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, featuremgmt.F }) if err != nil { - return nil, nil, err + return nil, nil, nil, err } features, err := featuremgmt.ProvideManagerService(cfg) - return cfg, features, err + if err != nil { + return nil, nil, nil, err + } + + tracingCfg, err := tracing.ProvideTracingConfig(cfg) + if err != nil { + return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer config", err) + } + + tracer, err := tracing.ProvideService(tracingCfg) + if err != nil { + return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) + } + + return cfg, tracer, features, err } func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx *cli.Context) (*ConflictResolver, error) { - cfg, features, err := initConflictCfg(cmd) + cfg, tracer, features, err := initConflictCfg(cmd) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) } - s, err := getSqlStore(cfg, features) + s, err := getSqlStore(cfg, tracer, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) } @@ -64,7 +78,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } quotaService := quotaimpl.ProvideService(s, cfg) - userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get user service", err) } @@ -78,16 +92,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return &resolver, nil } -func getSqlStore(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { - tracingCfg, err := tracing.ProvideTracingConfig(cfg) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to initialize tracer config", err) - } - - tracer, err := tracing.ProvideService(tracingCfg) - if err != nil { - return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) - } +func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { bus := bus.ProvideBus(tracer) return sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) } diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 89b65d8cd8ed8..fda8acff22486 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -91,9 +91,9 @@ 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, usrSvc, teamSvc, _ := setupTestEnv(t) - user, team := createUserAndTeam(t, store.sql, sql, teamSvc, tt.orgID) + user, team := createUserAndTeam(t, store.sql, usrSvc, teamSvc, tt.orgID) for _, id := range tt.userPermissions { _, err := permissionStore.SetUserResourcePermission(context.Background(), tt.orgID, accesscontrol.User{ID: user.ID}, rs.SetResourcePermissionCommand{ @@ -164,8 +164,8 @@ 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) - user, _ := createUserAndTeam(t, store.sql, sql, teamSvc, 1) + store, permissionsStore, usrSvc, teamSvc, _ := setupTestEnv(t) + user, _ := createUserAndTeam(t, store.sql, usrSvc, teamSvc, 1) // generate permissions in org 1 _, err := permissionsStore.SetUserResourcePermission(context.Background(), 1, accesscontrol.User{ID: user.ID}, rs.SetResourcePermissionCommand{ @@ -204,8 +204,8 @@ 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) - user, _ := createUserAndTeam(t, store.sql, sql, teamSvc, 1) + store, permissionsStore, usrSvc, teamSvc, _ := setupTestEnv(t) + user, _ := createUserAndTeam(t, store.sql, usrSvc, teamSvc, 1) // generate permissions in org 1 _, err := permissionsStore.SetUserResourcePermission(context.Background(), 1, accesscontrol.User{ID: user.ID}, rs.SetResourcePermissionCommand{ @@ -246,8 +246,8 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { func TestAccessControlStore_DeleteTeamPermissions(t *testing.T) { t.Run("expect permissions related to team to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) - user, team := createUserAndTeam(t, store.sql, sql, teamSvc, 1) + store, permissionsStore, usrSvc, teamSvc, _ := setupTestEnv(t) + user, team := createUserAndTeam(t, store.sql, usrSvc, teamSvc, 1) // grant permission to the team _, err := permissionsStore.SetTeamResourcePermission(context.Background(), 1, team.ID, rs.SetResourcePermissionCommand{ @@ -280,8 +280,8 @@ func TestAccessControlStore_DeleteTeamPermissions(t *testing.T) { assert.Len(t, permissions, 0) }) t.Run("expect permissions not related to team to be kept", func(t *testing.T) { - store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) - user, team := createUserAndTeam(t, store.sql, sql, teamSvc, 1) + store, permissionsStore, usrSvc, teamSvc, _ := setupTestEnv(t) + user, team := createUserAndTeam(t, store.sql, usrSvc, teamSvc, 1) // grant permission to the team _, err := permissionsStore.SetTeamResourcePermission(context.Background(), 1, team.ID, rs.SetResourcePermissionCommand{ @@ -409,7 +409,10 @@ func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, user.Service, te require.Equal(t, int64(1), orgID) require.NoError(t, err) - userService, err := userimpl.ProvideService(sql, orgService, cfg, teamService, localcache.ProvideService(), quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + userService, err := userimpl.ProvideService( + sql, orgService, cfg, teamService, localcache.ProvideService(), tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) return acstore, permissionStore, userService, teamService, orgService } diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 57d55ffdd8346..42732a3388a2e 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -13,19 +13,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/team/teamimpl" + "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/services/user/userimpl" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -117,7 +110,7 @@ func TestApi_getDescription(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _, _, _ := setupTestEnvironment(t, tt.options) + service, _, _ := setupTestEnvironment(t, tt.options) server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/access-control/%s/description", tt.options.Resource), nil) @@ -164,10 +157,10 @@ func TestApi_getPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql, cfg, _ := setupTestEnvironment(t, testOptions) + service, usrSvc, teamSvc := setupTestEnvironment(t, testOptions) server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) - seedPermissions(t, tt.resourceID, sql, cfg, service) + seedPermissions(t, tt.resourceID, usrSvc, teamSvc, service) permissions, recorder := getPermission(t, server, testOptions.Resource, tt.resourceID) assert.Equal(t, tt.expectedStatus, recorder.Code) @@ -241,7 +234,7 @@ func TestApi_setBuiltinRolePermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _, _, _ := setupTestEnvironment(t, testOptions) + service, _, _ := setupTestEnvironment(t, testOptions) server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "builtInRoles", tt.builtInRole) @@ -319,7 +312,7 @@ func TestApi_setTeamPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _, _, teamSvc := setupTestEnvironment(t, testOptions) + service, _, teamSvc := setupTestEnvironment(t, testOptions) server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) // seed team @@ -402,18 +395,13 @@ func TestApi_setUserPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql, cfg, _ := setupTestEnvironment(t, testOptions) + service, usrSvc, _ := setupTestEnvironment(t, testOptions) server := setupTestServer(t, &user.SignedInUser{ OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}, }, service) - // seed user - orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sql, orgSvc, cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) - _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) + _, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "users", strconv.Itoa(int(tt.userID))) @@ -507,20 +495,15 @@ func checkSeededPermissions(t *testing.T, permissions []resourcePermissionDTO) { } } -func seedPermissions(t *testing.T, resourceID string, sql db.DB, cfg *setting.Cfg, service *Service) { +func seedPermissions(t *testing.T, resourceID string, usrSvc user.Service, teamSvc team.Service, service *Service) { t.Helper() + // seed team 1 with "Edit" permission on dashboard 1 - teamSvc, err := teamimpl.ProvideService(sql, cfg, tracing.InitializeTracerForTest()) - require.NoError(t, err) team, err := teamSvc.CreateTeam(context.Background(), "test", "test@test.com", 1) require.NoError(t, err) _, err = service.SetTeamPermission(context.Background(), team.OrgID, team.ID, resourceID, "Edit") require.NoError(t, err) // seed user 1 with "View" permission on dashboard 1 - orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sql, orgSvc, cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) _, err = service.SetUserPermission(context.Background(), u.OrgID, accesscontrol.User{ID: u.ID}, resourceID, "View") diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index e3b152e65c7f8..44bc6a98514a6 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -44,17 +44,13 @@ func TestService_SetUserPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql, cfg, _ := setupTestEnvironment(t, Options{ + service, usrSvc, _ := setupTestEnvironment(t, Options{ Resource: "dashboards", Assignments: Assignments{Users: true}, PermissionsToActions: nil, }) // seed user - orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sql, orgSvc, cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) user, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) @@ -92,7 +88,7 @@ func TestService_SetTeamPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _, _, teamSvc := setupTestEnvironment(t, Options{ + service, _, teamSvc := setupTestEnvironment(t, Options{ Resource: "dashboards", Assignments: Assignments{Teams: true}, PermissionsToActions: nil, @@ -136,7 +132,7 @@ func TestService_SetBuiltInRolePermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _, _, _ := setupTestEnvironment(t, Options{ + service, _, _ := setupTestEnvironment(t, Options{ Resource: "dashboards", Assignments: Assignments{BuiltInRoles: true}, PermissionsToActions: nil, @@ -209,14 +205,10 @@ func TestService_SetPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql, cfg, teamSvc := setupTestEnvironment(t, tt.options) + service, usrSvc, teamSvc := setupTestEnvironment(t, tt.options) // seed user - orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sql, orgSvc, cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) - _, err = usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "user", OrgID: 1}) + _, err := usrSvc.Create(context.Background(), &user.CreateUserCommand{Login: "user", OrgID: 1}) require.NoError(t, err) _, err = teamSvc.CreateTeam(context.Background(), "team", "", 1) require.NoError(t, err) @@ -232,15 +224,25 @@ func TestService_SetPermissions(t *testing.T) { } } -func setupTestEnvironment(t *testing.T, ops Options) (*Service, db.DB, *setting.Cfg, team.Service) { +func setupTestEnvironment(t *testing.T, ops Options) (*Service, user.Service, team.Service) { t.Helper() sql := db.InitTestDB(t) cfg := setting.NewCfg() - teamSvc, err := teamimpl.ProvideService(sql, cfg, tracing.InitializeTracerForTest()) + tracer := tracing.InitializeTracerForTest() + + teamSvc, err := teamimpl.ProvideService(sql, cfg, tracer) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sql, nil, cfg, teamSvc, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + + orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) require.NoError(t, err) + + userSvc, err := userimpl.ProvideService( + sql, orgSvc, cfg, teamSvc, nil, tracer, + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + license := licensingtest.NewFakeLicensing() license.On("FeatureEnabled", "accesscontrol.enforcement").Return(true).Maybe() ac := acimpl.ProvideAccessControl(cfg) @@ -251,5 +253,5 @@ func setupTestEnvironment(t *testing.T, ops Options) (*Service, db.DB, *setting. ) require.NoError(t, err) - return service, sql, cfg, teamSvc + return service, userSvc, teamSvc } diff --git a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go index 5134bc871fd68..51a63a5deba93 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go @@ -147,7 +147,9 @@ func generateTeamsAndUsers(b *testing.B, store db.DB, cfg *setting.Cfg, users in qs := quotatest.New(false, nil) orgSvc, err := orgimpl.ProvideService(store, cfg, qs) require.NoError(b, err) - usrSvc, err := userimpl.ProvideService(store, orgSvc, cfg, nil, nil, qs, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + store, orgSvc, cfg, nil, nil, tracing.InitializeTracerForTest(), + qs, supportbundlestest.NewFakeBundleService()) require.NoError(b, err) userIds := make([]int64, 0) teamIds := make([]int64, 0) diff --git a/pkg/services/accesscontrol/resourcepermissions/store_test.go b/pkg/services/accesscontrol/resourcepermissions/store_test.go index fc6647de5aaa3..0c92a48162e6e 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -527,7 +528,10 @@ func seedResourcePermissions( orgID, err := orgService.GetOrCreate(context.Background(), "test") require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sql, orgService, cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sql, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) create := func(login string, isServiceAccount bool) { diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index b640c050842f8..76694da9d2624 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -24,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" - "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -252,6 +251,11 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { setup := func() { sqlStore, cfg = db.InitTestDBWithCfg(t) + cfg.AutoAssignOrg = true + cfg.AutoAssignOrgId = 1 + cfg.AutoAssignOrgRole = string(org.RoleViewer) + + tracer := tracing.InitializeTracerForTest() quotaService := quotatest.New(false, nil) // enable nested folders so that the folder table is populated for all the tests @@ -261,18 +265,21 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - usr := createUser(t, sqlStore, cfg, "viewer", "Viewer", false) + orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgService, cfg, nil, nil, tracer, + quotaService, supportbundlestest.NewFakeBundleService(), + ) + require.NoError(t, err) + + usr := createUser(t, usrSvc, orgService, "viewer", false) viewer = &user.SignedInUser{ UserID: usr.ID, OrgID: usr.OrgID, OrgRole: org.RoleViewer, } - orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) - // create admin user in the same org currentUserCmd := user.CreateUserCommand{Login: "admin", Email: "admin@test.com", Name: "an admin", IsAdmin: false, OrgID: viewer.OrgID} u, err := usrSvc.Create(context.Background(), ¤tUserCmd) @@ -298,7 +305,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { guardian.New = origNewGuardian }) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracer), cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil) parentUID := "" for i := 0; ; i++ { @@ -439,27 +446,14 @@ func moveDashboard(t *testing.T, dashboardStore dashboards.Store, orgId int64, d return dash } -func createUser(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, name string, role string, isAdmin bool) user.User { +func createUser(t *testing.T, userSrv user.Service, orgSrv org.Service, name string, isAdmin bool) user.User { t.Helper() - cfg.AutoAssignOrg = true - cfg.AutoAssignOrgId = 1 - cfg.AutoAssignOrgRole = role - - qs := quotaimpl.ProvideService(sqlStore, cfg) - orgService, err := orgimpl.ProvideService(sqlStore, cfg, qs) - require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, nil, nil, qs, supportbundlestest.NewFakeBundleService()) - require.NoError(t, err) - o, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: fmt.Sprintf("test org %d", time.Now().UnixNano())}) + o, err := orgSrv.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: fmt.Sprintf("test org %d", time.Now().UnixNano())}) require.NoError(t, err) currentUserCmd := user.CreateUserCommand{Login: name, Email: name + "@test.com", Name: "a " + name, IsAdmin: isAdmin, OrgID: o.ID} - currentUser, err := usrSvc.Create(context.Background(), ¤tUserCmd) - require.NoError(t, err) - orgs, err := orgService.GetUserOrgList(context.Background(), &org.GetUserOrgListQuery{UserID: currentUser.ID}) + currentUser, err := userSrv.Create(context.Background(), ¤tUserCmd) require.NoError(t, err) - require.Equal(t, org.RoleType(role), orgs[0].Role) - require.Equal(t, o.ID, orgs[0].OrgID) return *currentUser } diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index b7fec91dac9ac..7dda316dc3615 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -439,6 +439,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo webCtx := web.Context{Req: req} features := featuremgmt.WithFeatures() + tracer := tracing.InitializeTracerForTest() sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) @@ -460,7 +461,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo Cfg: cfg, features: featuremgmt.WithFeatures(), SQLStore: sqlStore, - folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil), + folderService: folderimpl.ProvideService(ac, bus.ProvideBus(tracer), cfg, dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil), } // deliberate difference between signed in user and user in db to make it crystal clear @@ -473,7 +474,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo } orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, nil, nil, tracer, + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 2ff4d3f84f303..771f673b078dd 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -872,7 +872,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo ctx := appcontext.WithUser(context.Background(), usr) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) _, err = usrSvc.Create(context.Background(), &cmd) require.NoError(t, err) diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 37c9f580d21cc..e946e97ca3db4 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/org" @@ -908,7 +909,10 @@ func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Servi quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := ProvideService(store, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(store, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) return orgService, usrSvc diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go index 2b6a1cee6ef5d..5e7b8cad7d8e6 100644 --- a/pkg/services/queryhistory/queryhistory_test.go +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -65,7 +66,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo quotaService := quotatest.New(false, nil) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) usr := user.SignedInUser{ diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 3d127cb6d581e..aa13fa80b035d 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -94,7 +94,10 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { quotaService := ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - userService, err := userimpl.ProvideService(sqlStore, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + userService, err := userimpl.ProvideService( + sqlStore, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) setupEnv(t, sqlStore, cfg, b, quotaService) diff --git a/pkg/services/serviceaccounts/database/store_test.go b/pkg/services/serviceaccounts/database/store_test.go index dfd9b688edb03..5804a3fe8e6c8 100644 --- a/pkg/services/serviceaccounts/database/store_test.go +++ b/pkg/services/serviceaccounts/database/store_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" + "github.com/grafana/grafana/pkg/infra/tracing" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" @@ -228,7 +229,10 @@ func setupTestDatabase(t *testing.T) (db.DB, *ServiceAccountsStoreImpl) { kvStore := kvstore.ProvideService(db) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) return db, ProvideServiceAccountsStore(cfg, db, apiKeyService, kvStore, userSvc, orgService) } diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index dce8daeffe2b2..66d15e35b9dbb 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey/apikeyimpl" "github.com/grafana/grafana/pkg/services/org" @@ -44,7 +45,10 @@ func SetupUserServiceAccount(t *testing.T, db db.DB, cfg *setting.Cfg, testUser quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) org, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{ @@ -111,7 +115,10 @@ func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, t quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(sqlStore, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + sqlStore, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) org, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{ diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index f686c6a42514e..e63d51fddc09c 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -87,7 +87,10 @@ func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) { t.Helper() orgService, _ := orgimpl.ProvideService(db, cfg, quotatest.New(false, nil)) - userSvc, _ := userimpl.ProvideService(db, orgService, cfg, nil, nil, "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService()) + userSvc, _ := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + "atest.FakeQuotaService{}, supportbundlestest.NewFakeBundleService(), + ) bus := bus.ProvideBus(tracing.InitializeTracerForTest()) correlationsSvc := correlationstest.New(db, cfg, bus) diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 62126495417f2..4fdeaab2ba4e7 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -51,8 +51,10 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, teamSvc, nil, quotaService, - supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) t.Run("Given saved users and two teams", func(t *testing.T) { @@ -436,7 +438,10 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, teamSvc, nil, quotaService, supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + sqlStore, orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) setup() userCmd = user.CreateUserCommand{ @@ -571,7 +576,10 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { quotaService := quotaimpl.ProvideService(store, cfg) orgSvc, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) - userSvc, err := userimpl.ProvideService(store, orgSvc, cfg, teamSvc, nil, quotaService, supportbundlestest.NewFakeBundleService()) + userSvc, err := userimpl.ProvideService( + store, orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) for i := 0; i < 4; i++ { diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 5f768320051ee..45ed734c2f78c 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -37,7 +38,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) - usrSvc, err := ProvideService(ss, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := ProvideService( + ss, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) usr := &user.SignedInUser{ OrgID: 1, @@ -554,7 +558,10 @@ func TestIntegrationUserDataAccess(t *testing.T) { ss := db.InitTestDB(t) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) - usrSvc, err := ProvideService(ss, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := ProvideService( + ss, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) createFiveTestUsers(t, usrSvc, func(i int) *user.CreateUserCommand { @@ -958,7 +965,10 @@ func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Servi quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) - usrSvc, err := ProvideService(store, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := ProvideService( + store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) return orgService, usrSvc diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go index 8c885e4695ea9..c272934fa85fa 100644 --- a/pkg/services/user/userimpl/user.go +++ b/pkg/services/user/userimpl/user.go @@ -7,8 +7,12 @@ import ( "strings" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" @@ -27,6 +31,7 @@ type Service struct { teamService team.Service cacheService *localcache.CacheService cfg *setting.Cfg + tracer tracing.Tracer } func ProvideService( @@ -34,9 +39,8 @@ func ProvideService( orgService org.Service, cfg *setting.Cfg, teamService team.Service, - cacheService *localcache.CacheService, - quotaService quota.Service, - bundleRegistry supportbundles.Service, + cacheService *localcache.CacheService, tracer tracing.Tracer, + quotaService quota.Service, bundleRegistry supportbundles.Service, ) (user.Service, error) { store := ProvideStore(db, cfg) s := &Service{ @@ -45,6 +49,7 @@ func ProvideService( cfg: cfg, teamService: teamService, cacheService: cacheService, + tracer: tracer, } defaultLimits, err := readQuotaConfig(cfg) @@ -55,7 +60,7 @@ func ProvideService( if err := quotaService.RegisterQuotaReporter("a.NewUsageReporter{ TargetSrv: quota.TargetSrv(user.QuotaTargetSrv), DefaultLimits: defaultLimits, - Reporter: s.Usage, + Reporter: s.usage, }); err != nil { return s, err } @@ -87,21 +92,10 @@ func (s *Service) GetUsageStats(ctx context.Context) map[string]any { return stats } -func (s *Service) Usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { - u := "a.Map{} - if used, err := s.store.Count(ctx); err != nil { - return u, err - } else { - tag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) - if err != nil { - return u, err - } - u.Set(tag, used) - } - return u, nil -} - func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + ctx, span := s.tracer.Start(ctx, "user.Create") + defer span.End() + if len(cmd.Login) == 0 { cmd.Login = cmd.Email } @@ -126,8 +120,7 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use cmd.Email = cmd.Login } - err = s.store.LoginConflict(ctx, cmd.Login, cmd.Email) - if err != nil { + if err := s.store.LoginConflict(ctx, cmd.Login, cmd.Email); err != nil { return nil, user.ErrUserAlreadyExists } @@ -202,27 +195,48 @@ func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*use } func (s *Service) Delete(ctx context.Context, cmd *user.DeleteUserCommand) error { + ctx, span := s.tracer.Start(ctx, "user.Delete", trace.WithAttributes( + attribute.Int64("userID", cmd.UserID), + )) + defer span.End() + _, err := s.store.GetByID(ctx, cmd.UserID) if err != nil { return err } - // delete from all the stores + return s.store.Delete(ctx, cmd.UserID) } func (s *Service) GetByID(ctx context.Context, query *user.GetUserByIDQuery) (*user.User, error) { + ctx, span := s.tracer.Start(ctx, "user.GetByID", trace.WithAttributes( + attribute.Int64("userID", query.ID), + )) + defer span.End() + return s.store.GetByID(ctx, query.ID) } func (s *Service) GetByLogin(ctx context.Context, query *user.GetUserByLoginQuery) (*user.User, error) { + ctx, span := s.tracer.Start(ctx, "user.GetByLogin") + defer span.End() + return s.store.GetByLogin(ctx, query) } func (s *Service) GetByEmail(ctx context.Context, query *user.GetUserByEmailQuery) (*user.User, error) { + ctx, span := s.tracer.Start(ctx, "user.GetByEmail") + defer span.End() + return s.store.GetByEmail(ctx, query) } func (s *Service) Update(ctx context.Context, cmd *user.UpdateUserCommand) error { + ctx, span := s.tracer.Start(ctx, "user.Update", trace.WithAttributes( + attribute.Int64("userID", cmd.UserID), + )) + defer span.End() + usr, err := s.store.GetByID(ctx, cmd.UserID) if err != nil { return err @@ -273,6 +287,11 @@ func (s *Service) Update(ctx context.Context, cmd *user.UpdateUserCommand) error } func (s *Service) UpdateLastSeenAt(ctx context.Context, cmd *user.UpdateUserLastSeenAtCommand) error { + ctx, span := s.tracer.Start(ctx, "user.UpdateLastSeen", trace.WithAttributes( + attribute.Int64("userID", cmd.UserID), + )) + defer span.End() + u, err := s.GetSignedInUserWithCacheCtx(ctx, &user.GetSignedInUserQuery{ UserID: cmd.UserID, OrgID: cmd.OrgID, @@ -294,6 +313,12 @@ func shouldUpdateLastSeen(t time.Time) bool { } func (s *Service) GetSignedInUserWithCacheCtx(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) { + ctx, span := s.tracer.Start(ctx, "user.GetSignedInUserWithCacheCtx", trace.WithAttributes( + attribute.Int64("userID", query.UserID), + attribute.Int64("orgID", query.OrgID), + )) + defer span.End() + var signedInUser *user.SignedInUser // only check cache if we have a user ID and an org ID in query @@ -321,54 +346,62 @@ func newSignedInUserCacheKey(orgID, userID int64) string { } func (s *Service) GetSignedInUser(ctx context.Context, query *user.GetSignedInUserQuery) (*user.SignedInUser, error) { - signedInUser, err := s.store.GetSignedInUser(ctx, query) + ctx, span := s.tracer.Start(ctx, "user.GetSignedInUser", trace.WithAttributes( + attribute.Int64("userID", query.UserID), + attribute.Int64("orgID", query.OrgID), + )) + defer span.End() + + usr, err := s.store.GetSignedInUser(ctx, query) if err != nil { return nil, err } - getTeamsByUserQuery := &team.GetTeamIDsByUserQuery{ - OrgID: signedInUser.OrgID, - UserID: signedInUser.UserID, - } - signedInUser.Teams, err = s.teamService.GetTeamIDsByUser(ctx, getTeamsByUserQuery) + usr.Teams, err = s.teamService.GetTeamIDsByUser(ctx, &team.GetTeamIDsByUserQuery{ + OrgID: usr.OrgID, + UserID: usr.UserID, + }) if err != nil { return nil, err } - return signedInUser, err + return usr, err } func (s *Service) Search(ctx context.Context, query *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) { + ctx, span := s.tracer.Start(ctx, "user.Search", trace.WithAttributes( + attribute.Int64("orgID", query.OrgID), + )) + defer span.End() + return s.store.Search(ctx, query) } func (s *Service) BatchDisableUsers(ctx context.Context, cmd *user.BatchDisableUsersCommand) error { + ctx, span := s.tracer.Start(ctx, "user.BatchDisableUsers", trace.WithAttributes( + attribute.Int64Slice("userIDs", cmd.UserIDs), + )) + defer span.End() + return s.store.BatchDisableUsers(ctx, cmd) } func (s *Service) GetProfile(ctx context.Context, query *user.GetUserProfileQuery) (*user.UserProfileDTO, error) { - result, err := s.store.GetProfile(ctx, query) - return result, err -} - -func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { - limits := "a.Map{} - - if cfg == nil { - return limits, nil - } + ctx, span := s.tracer.Start(ctx, "user.GetProfile", trace.WithAttributes( + attribute.Int64("userID", query.UserID), + )) + defer span.End() - globalQuotaTag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) - if err != nil { - return limits, err - } - - limits.Set(globalQuotaTag, cfg.Quota.Global.User) - return limits, nil + return s.store.GetProfile(ctx, query) } // CreateServiceAccount creates a service account in the user table and adds service account to an organisation in the org_user table func (s *Service) CreateServiceAccount(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) { + ctx, span := s.tracer.Start(ctx, "user.CreateServiceAccount", trace.WithAttributes( + attribute.Int64("orgID", cmd.OrgID), + )) + defer span.End() + cmd.Email = cmd.Login err := s.store.LoginConflict(ctx, cmd.Login, cmd.Email) if err != nil { @@ -462,6 +495,36 @@ func (s *Service) supportBundleCollector() supportbundles.Collector { } } +func (s *Service) usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.Map, error) { + u := "a.Map{} + if used, err := s.store.Count(ctx); err != nil { + return u, err + } else { + tag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return u, err + } + u.Set(tag, used) + } + return u, nil +} + +func readQuotaConfig(cfg *setting.Cfg) (*quota.Map, error) { + limits := "a.Map{} + + if cfg == nil { + return limits, nil + } + + globalQuotaTag, err := quota.NewTag(quota.TargetSrv(user.QuotaTargetSrv), quota.Target(user.QuotaTarget), quota.GlobalScope) + if err != nil { + return limits, err + } + + limits.Set(globalQuotaTag, cfg.Quota.Global.User) + return limits, nil +} + // This is just to ensure that all users have a valid uid. // To protect against upgrade / downgrade we need to run this for a couple of releases. // FIXME: Remove this migration and make uid field required https://github.com/grafana/identity-access-team/issues/552 diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go index 0b9a90b854bb3..6cc9512507c56 100644 --- a/pkg/services/user/userimpl/user_test.go +++ b/pkg/services/user/userimpl/user_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/team/teamtest" @@ -25,6 +26,7 @@ func TestUserService(t *testing.T) { orgService: orgService, cacheService: localcache.ProvideService(), teamService: &teamtest.FakeService{}, + tracer: tracing.InitializeTracerForTest(), } userService.cfg = setting.NewCfg() @@ -100,6 +102,7 @@ func TestUserService(t *testing.T) { orgService: orgService, cacheService: localcache.ProvideService(), teamService: teamtest.NewFakeService(), + tracer: tracing.InitializeTracerForTest(), } usr := &user.SignedInUser{ OrgID: 1, @@ -149,7 +152,10 @@ func TestUserService(t *testing.T) { func TestService_Update(t *testing.T) { setup := func(opts ...func(svc *Service)) *Service { - service := &Service{store: &FakeUserStore{}} + service := &Service{ + store: &FakeUserStore{}, + tracer: tracing.InitializeTracerForTest(), + } for _, o := range opts { o(service) } @@ -204,6 +210,33 @@ func TestService_Update(t *testing.T) { }) } +func TestUpdateLastSeenAt(t *testing.T) { + userStore := newUserStoreFake() + orgService := orgtest.NewOrgServiceFake() + userService := Service{ + store: userStore, + orgService: orgService, + cacheService: localcache.ProvideService(), + teamService: &teamtest.FakeService{}, + tracer: tracing.InitializeTracerForTest(), + } + userService.cfg = setting.NewCfg() + + t.Run("update last seen at", func(t *testing.T) { + userStore.ExpectedSignedInUser = &user.SignedInUser{UserID: 1, OrgID: 1, Email: "email", Login: "login", Name: "name", LastSeenAt: time.Now().Add(-10 * time.Minute)} + err := userService.UpdateLastSeenAt(context.Background(), &user.UpdateUserLastSeenAtCommand{UserID: 1, OrgID: 1}) + require.NoError(t, err) + }) + + userService.cacheService.Flush() + + t.Run("do not update last seen at", func(t *testing.T) { + userStore.ExpectedSignedInUser = &user.SignedInUser{UserID: 1, OrgID: 1, Email: "email", Login: "login", Name: "name", LastSeenAt: time.Now().Add(-1 * time.Minute)} + err := userService.UpdateLastSeenAt(context.Background(), &user.UpdateUserLastSeenAtCommand{UserID: 1, OrgID: 1}) + require.ErrorIs(t, err, user.ErrLastSeenUpToDate, err) + }) +} + func TestMetrics(t *testing.T) { userStore := newUserStoreFake() orgService := orgtest.NewOrgServiceFake() @@ -213,6 +246,7 @@ func TestMetrics(t *testing.T) { orgService: orgService, cacheService: localcache.ProvideService(), teamService: &teamtest.FakeService{}, + tracer: tracing.InitializeTracerForTest(), } t.Run("update user with role None", func(t *testing.T) { @@ -303,29 +337,3 @@ func (f *FakeUserStore) Count(ctx context.Context) (int64, error) { func (f *FakeUserStore) CountUserAccountsWithEmptyRole(ctx context.Context) (int64, error) { return f.ExpectedCountUserAccountsWithEmptyRoles, nil } - -func TestUpdateLastSeenAt(t *testing.T) { - userStore := newUserStoreFake() - orgService := orgtest.NewOrgServiceFake() - userService := Service{ - store: userStore, - orgService: orgService, - cacheService: localcache.ProvideService(), - teamService: &teamtest.FakeService{}, - } - userService.cfg = setting.NewCfg() - - t.Run("update last seen at", func(t *testing.T) { - userStore.ExpectedSignedInUser = &user.SignedInUser{UserID: 1, OrgID: 1, Email: "email", Login: "login", Name: "name", LastSeenAt: time.Now().Add(-10 * time.Minute)} - err := userService.UpdateLastSeenAt(context.Background(), &user.UpdateUserLastSeenAtCommand{UserID: 1, OrgID: 1}) - require.NoError(t, err) - }) - - userService.cacheService.Flush() - - t.Run("do not update last seen at", func(t *testing.T) { - userStore.ExpectedSignedInUser = &user.SignedInUser{UserID: 1, OrgID: 1, Email: "email", Login: "login", Name: "name", LastSeenAt: time.Now().Add(-1 * time.Minute)} - err := userService.UpdateLastSeenAt(context.Background(), &user.UpdateUserLastSeenAtCommand{UserID: 1, OrgID: 1}) - require.ErrorIs(t, err, user.ErrLastSeenUpToDate, err) - }) -} diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index afb2e92089702..653def960c58c 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -2649,7 +2650,10 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index a42a497eda391..c14f4b3a9376b 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/datasources" @@ -160,7 +161,10 @@ func (c TestContext) createUser(cmd user.CreateUserCommand) User { quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) - usrSvc, err := userimpl.ProvideService(store, orgService, c.env.Cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + store, orgService, c.env.Cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(c.t, err) user, err := usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 0b280eed3d3ac..604f0df60b8ab 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" @@ -123,7 +124,10 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/api/folders/api_folder_test.go b/pkg/tests/api/folders/api_folder_test.go index 8c959967b31d6..c61e94b0cd97c 100644 --- a/pkg/tests/api/folders/api_folder_test.go +++ b/pkg/tests/api/folders/api_folder_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana-openapi-client-go/client/folders" "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -215,7 +216,10 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index a026f9d83365b..ba04251c1fb70 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" @@ -201,7 +202,10 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) _, err = usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index 019fd8608bcf6..3e264a75e0c5e 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" @@ -89,7 +90,10 @@ func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &cmd) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 74afbfbe1f01a..c491dea38472e 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -397,8 +397,9 @@ func (c K8sTestHelper) createTestUsers(orgName string) OrgUsers { require.NoError(c.t, err) cache := localcache.ProvideService() - userSvc, err := userimpl.ProvideService(store, - orgService, c.env.Cfg, teamSvc, cache, quotaService, + userSvc, err := userimpl.ProvideService( + store, orgService, c.env.Cfg, teamSvc, + cache, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService()) require.NoError(c.t, err) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 24b046d7aed3f..c5c9b14452cef 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/fs" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -442,7 +443,9 @@ func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUser quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(store, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) o, err := orgService.CreateWithMember(context.Background(), &org.CreateOrgCommand{Name: fmt.Sprintf("test org %d", time.Now().UnixNano())}) diff --git a/pkg/tests/utils.go b/pkg/tests/utils.go index 4f4d7fcc28ad6..f81683c80f4ff 100644 --- a/pkg/tests/utils.go +++ b/pkg/tests/utils.go @@ -10,6 +10,7 @@ import ( "github.com/go-openapi/strfmt" goapi "github.com/grafana/grafana-openapi-client-go/client" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" @@ -30,7 +31,10 @@ func CreateUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCom quotaService := quotaimpl.ProvideService(db, cfg) orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) - usrSvc, err := userimpl.ProvideService(db, orgService, cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + usrSvc, err := userimpl.ProvideService( + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + quotaService, supportbundlestest.NewFakeBundleService(), + ) require.NoError(t, err) u, err := usrSvc.Create(context.Background(), &cmd)