Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Marbach
ec42bbaba9 BigValue: Make height, width optional; remove graphMode 2026-01-09 13:07:41 -05:00
66 changed files with 694 additions and 3894 deletions

View File

@@ -290,7 +290,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"percent"
@@ -304,7 +304,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -323,15 +323,6 @@
}
],
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -375,7 +366,7 @@
],
"legend": {
"displayMode": "table",
"placement": "right",
"placement": "bottom",
"showLegend": true,
"values": [
"value"
@@ -389,7 +380,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -408,15 +399,6 @@
}
],
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{

View File

@@ -248,7 +248,7 @@
"legend": {
"values": ["percent"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -256,7 +256,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -272,15 +272,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Percent",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "^Backend-(.*)$",
"renamePattern": "b-$1"
}
}
],
"type": "piechart"
},
{
@@ -320,7 +311,7 @@
"legend": {
"values": ["value"],
"displayMode": "table",
"placement": "right"
"placement": "bottom"
},
"pieType": "pie",
"reduceOptions": {
@@ -328,7 +319,7 @@
"fields": "",
"values": false
},
"showLegend": true,
"showLegend": false,
"strokeWidth": 1,
"text": {}
},
@@ -344,15 +335,6 @@
"timeFrom": null,
"timeShift": null,
"title": "Value",
"transformations": [
{
"id": "renameByRegex",
"options": {
"regex": "(.*)",
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
}
}
],
"type": "piechart"
},
{

View File

@@ -48,23 +48,6 @@ scopes:
operator: equals
value: kids
# This scope appears in multiple places in the tree.
# The defaultPath determines which path is shown when this scope is selected
# (e.g., from a URL or programmatically), even if another path also links to it.
shared-service:
title: Shared Service
# Path from the root node down to the direct scopeNode.
# Node names are hierarchical (parent-child), so use the full names.
# This points to: gdev-scopes > production > shared-service-prod
defaultPath:
- gdev-scopes
- gdev-scopes-production
- gdev-scopes-production-shared-service-prod
filters:
- key: service
operator: equals
value: shared
tree:
gdev-scopes:
title: gdev-scopes
@@ -85,13 +68,6 @@ tree:
nodeType: leaf
linkId: app2
linkType: scope
# This node links to 'shared-service' scope.
# The scope's defaultPath points here (production > gdev-scopes).
shared-service-prod:
title: Shared Service
nodeType: leaf
linkId: shared-service
linkType: scope
test-cases:
title: Test cases
nodeType: container
@@ -107,15 +83,6 @@ tree:
nodeType: leaf
linkId: test-case-2
linkType: scope
# This node also links to the same 'shared-service' scope.
# However, the scope's defaultPath points to the production path,
# so selecting this scope will expand the tree to production > shared-service-prod.
shared-service-test:
title: Shared Service (also in Production)
subTitle: defaultPath points to Production
nodeType: leaf
linkId: shared-service
linkType: scope
test-case-redirect:
title: Test case with redirect
nodeType: leaf

View File

@@ -51,9 +51,8 @@ type Config struct {
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
type ScopeConfig struct {
Title string `yaml:"title"`
DefaultPath []string `yaml:"defaultPath,omitempty"`
Filters []ScopeFilterConfig `yaml:"filters"`
Title string `yaml:"title"`
Filters []ScopeFilterConfig `yaml:"filters"`
}
// ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter
@@ -117,20 +116,9 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec {
for i, f := range cfg.Filters {
filters[i] = convertFilter(f)
}
// Prefix defaultPath elements with the gdev prefix
var defaultPath []string
if len(cfg.DefaultPath) > 0 {
defaultPath = make([]string, len(cfg.DefaultPath))
for i, p := range cfg.DefaultPath {
defaultPath[i] = prefix + "-" + p
}
}
return v0alpha1.ScopeSpec{
Title: cfg.Title,
DefaultPath: defaultPath,
Filters: filters,
Title: cfg.Title,
Filters: filters,
}
}

View File

@@ -231,10 +231,6 @@ JSON Body schema:
**Example subsequent request using continue token**:
```http
```
The `metadata.continue` field contains a token to fetch the next page.
GET /apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards?limit=1&continue=eyJvIjoxNTIsInYiOjE3NjE3MDQyMjQyMDcxODksInMiOmZhbHNlfQ== HTTP/1.1
Accept: application/json
Content-Type: application/json
@@ -525,10 +521,6 @@ JSON Body schema:
```
Status Codes:
```
Status Codes:
- **200** Deleted
- **401** Unauthorized

View File

@@ -259,10 +259,6 @@ JSON Body schema:
If [Grafana Alerting](ref:alerting) is enabled, you can set an optional query parameter `forceDeleteRules=false` so that requests will fail with 400 (Bad Request) error if the folder contains any Grafana alerts. However, if this parameter is set to `true` then it will delete any Grafana alerts under this folder.
### Delete folder
`DELETE /apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:uid`
- namespace: to read more about the namespace to use, see the [API overview](ref:apis).
- uid: the unique identifier of the folder to delete. this will be the _name_ in the folder response
@@ -341,10 +337,6 @@ JSON Body schema:
```http
HTTP/1.1 200
**Example Response**:
```http
HTTP/1.1 200
Content-Type: application/json
```

View File

@@ -51,8 +51,6 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] }
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
// Press Escape to close tooltip on the close button
await page.keyboard.press('Escape');
// Press Escape to close inspector
await page.keyboard.press('Escape');

View File

@@ -58,8 +58,6 @@ test.describe(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
// Press Escape to close tooltip on the close button
await page.keyboard.press('Escape');
// Press Escape to close inspector
await page.keyboard.press('Escape');

View File

@@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100);
// click cell inspect, check that cell inspection pops open in the side as we'd expect.
await loremIpsumCell.getByLabel('Inspect value').click();
const loremIpsumText = await loremIpsumCell.textContent();
expect(loremIpsumText).toBeDefined();
await loremIpsumCell.getByLabel('Inspect value').click();
await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible();
});

View File

@@ -5340,7 +5340,6 @@ export type OrgUserDto = {
};
authLabels?: string[];
avatarUrl?: string;
created?: string;
email?: string;
isDisabled?: boolean;
isExternallySynced?: boolean;
@@ -6119,7 +6118,6 @@ export type ChangeUserPasswordCommand = {
export type UserSearchHitDto = {
authLabels?: string[];
avatarUrl?: string;
created?: string;
email?: string;
id?: number;
isAdmin?: boolean;

View File

@@ -42,14 +42,7 @@ An example usage is in the [Stat panel](https://grafana.com/docs/grafana/latest/
```tsx
import { DisplayValue } from '@grafana/data';
import {
BigValue,
BigValueColorMode,
BigValueGraphMode,
BigValueJustifyMode,
BigValueTextMode,
useTheme,
} from '@grafana/ui';
import { BigValue, BigValueColorMode, BigValueJustifyMode, BigValueTextMode, useTheme } from '@grafana/ui';
const bigValue: DisplayValue = {
color: 'red',
@@ -62,7 +55,6 @@ return (
<BigValue
theme={useTheme()}
justifyMode={BigValueJustifyMode.Auto}
graphMode={BigValueGraphMode.Area}
colorMode={BigValueColorMode.Value}
textMode={BigValueTextMode.Auto}
value={bigValue}

View File

@@ -10,7 +10,7 @@ import {
BigValueGraphMode,
BigValueJustifyMode,
BigValueTextMode,
Props,
BigValueProps,
} from './BigValue';
import mdx from './BigValue.mdx';
@@ -58,7 +58,7 @@ const meta: Meta = {
},
};
interface StoryProps extends Props {
interface StoryProps extends BigValueProps {
numeric: number;
title: string;
color: string;
@@ -95,7 +95,6 @@ export const ApplyNoValue: StoryFn<StoryProps> = ({
width={width}
height={height}
colorMode={colorMode}
graphMode={graphMode}
textMode={textMode}
justifyMode={justifyMode}
value={{
@@ -109,6 +108,18 @@ export const ApplyNoValue: StoryFn<StoryProps> = ({
);
};
ApplyNoValue.args = {
valueText: '$5022',
title: 'Total Earnings',
colorMode: BigValueColorMode.Value,
graphMode: BigValueGraphMode.Area,
justifyMode: BigValueJustifyMode.Auto,
width: 400,
height: 300,
color: 'red',
textMode: BigValueTextMode.Auto,
};
export const Basic: StoryFn<StoryProps> = ({
valueText,
title,
@@ -137,7 +148,6 @@ export const Basic: StoryFn<StoryProps> = ({
width={width}
height={height}
colorMode={colorMode}
graphMode={graphMode}
textMode={textMode}
justifyMode={justifyMode}
value={{
@@ -163,14 +173,53 @@ Basic.args = {
textMode: BigValueTextMode.Auto,
};
ApplyNoValue.args = {
export const Flexible: StoryFn<StoryProps> = ({
valueText,
title,
colorMode,
graphMode,
height,
width,
color,
textMode,
justifyMode,
}) => {
const theme = useTheme2();
const sparkline: FieldSparkline = {
y: {
name: '',
values: [1, 2, 3, 4, 3],
type: FieldType.number,
state: { range: { min: 1, max: 4, delta: 3 } },
config: {},
},
};
return (
<BigValue
theme={theme}
colorMode={colorMode}
textMode={textMode}
justifyMode={justifyMode}
height={NaN}
width={NaN}
value={{
text: valueText,
numeric: 5022,
color: color,
title,
}}
sparkline={graphMode === BigValueGraphMode.None ? undefined : sparkline}
/>
);
};
Flexible.args = {
valueText: '$5022',
title: 'Total Earnings',
colorMode: BigValueColorMode.Value,
graphMode: BigValueGraphMode.Area,
justifyMode: BigValueJustifyMode.Auto,
width: 400,
height: 300,
color: 'red',
textMode: BigValueTextMode.Auto,
};

View File

@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import { createTheme } from '@grafana/data';
import { BigValue, BigValueColorMode, BigValueGraphMode, Props } from './BigValue';
import { BigValue, BigValueColorMode, BigValueProps } from './BigValue';
const valueObject = {
text: '25',
@@ -10,10 +10,9 @@ const valueObject = {
color: 'red',
};
function getProps(propOverrides?: Partial<Props>): Props {
const props: Props = {
function getProps(propOverrides?: Partial<BigValueProps>): BigValueProps {
const props: BigValueProps = {
colorMode: BigValueColorMode.Background,
graphMode: BigValueGraphMode.Line,
height: 300,
width: 300,
value: valueObject,

View File

@@ -1,5 +1,6 @@
import { cx } from '@emotion/css';
import { memo, type MouseEventHandler } from 'react';
import { useMeasure } from 'react-use';
import { DisplayValue, DisplayValueAlignmentFactors, FieldSparkline } from '@grafana/data';
import { PercentChangeColorMode, VizTextDisplayOptions } from '@grafana/schema';
@@ -18,6 +19,7 @@ export enum BigValueColorMode {
Value = 'value',
}
/** @deprecated use `sparkline` to configure the graph */
export enum BigValueGraphMode {
None = 'none',
Line = 'line',
@@ -40,11 +42,11 @@ export enum BigValueTextMode {
None = 'none',
}
export interface Props extends Themeable2 {
export interface BigValueProps extends Themeable2 {
/** Height of the component */
height: number;
height?: number;
/** Width of the component */
width: number;
width?: number;
/** Value displayed as Big Value */
value: DisplayValue;
/** Sparkline values for showing a graph under/behind the value */
@@ -55,8 +57,6 @@ export interface Props extends Themeable2 {
className?: string;
/** Color mode for coloring the value or the background */
colorMode: BigValueColorMode;
/** Show a graph behind/under the value */
graphMode: BigValueGraphMode;
/** Auto justify value and text or center it */
justifyMode?: BigValueJustifyMode;
/** Factors that should influence the positioning of the text */
@@ -80,6 +80,9 @@ export interface Props extends Themeable2 {
* Disable the wide layout for the BigValue
*/
disableWideLayout?: boolean;
/** @deprecated use `sparkline` to configure the graph */
graphMode?: BigValueGraphMode;
}
/**
@@ -87,10 +90,22 @@ export interface Props extends Themeable2 {
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-bigvalue--docs
*/
export const BigValue = memo<Props>((props) => {
const { onClick, className, hasLinks, theme, justifyMode = BigValueJustifyMode.Auto } = props;
export const BigValue = memo<BigValueProps>((props) => {
const {
onClick,
className,
hasLinks,
theme,
justifyMode = BigValueJustifyMode.Auto,
height: propsHeight,
width: propsWidth,
} = props;
const [wrapperRef, { width: calcWidth, height: calcHeight }] = useMeasure<HTMLDivElement>();
const layout = buildLayout({ ...props, justifyMode });
const height = propsHeight ?? calcHeight;
const width = propsWidth ?? calcWidth;
const layout = buildLayout({ ...props, justifyMode, height, width });
const panelStyles = layout.getPanelStyles();
const valueAndTitleContainerStyles = layout.getValueAndTitleContainerStyles();
const valueStyles = layout.getValueStyles();
@@ -105,7 +120,7 @@ export const BigValue = memo<Props>((props) => {
if (!onClick) {
return (
<div className={className} style={panelStyles} title={tooltip}>
<div ref={wrapperRef} className={className} style={panelStyles} title={tooltip}>
<div style={valueAndTitleContainerStyles}>
{textValues.title && <div style={titleStyles}>{textValues.title}</div>}
<FormattedValueDisplay value={textValues} style={valueStyles} />
@@ -129,7 +144,7 @@ export const BigValue = memo<Props>((props) => {
onClick={onClick}
title={tooltip}
>
<div style={valueAndTitleContainerStyles}>
<div ref={wrapperRef} style={valueAndTitleContainerStyles}>
{textValues.title && <div style={titleStyles}>{textValues.title}</div>}
<FormattedValueDisplay value={textValues} style={valueStyles} />
</div>

View File

@@ -3,19 +3,19 @@ import { CSSProperties } from 'react';
import { createTheme, FieldType } from '@grafana/data';
import { PercentChangeColorMode } from '@grafana/schema';
import { Props, BigValueColorMode, BigValueGraphMode, BigValueTextMode } from './BigValue';
import { BigValueProps, BigValueColorMode, BigValueTextMode } from './BigValue';
import {
buildLayout,
getPercentChangeColor,
StackedWithChartLayout,
StackedWithNoChartLayout,
WideWithChartLayout,
BigValuePropsWithHeightAndWidth,
} from './BigValueLayout';
function getProps(propOverrides?: Partial<Props>): Props {
const props: Props = {
function getProps(propOverrides?: Partial<BigValuePropsWithHeightAndWidth>): BigValuePropsWithHeightAndWidth {
const props: BigValuePropsWithHeightAndWidth = {
colorMode: BigValueColorMode.Background,
graphMode: BigValueGraphMode.Area,
height: 300,
width: 300,
value: {
@@ -105,7 +105,7 @@ describe('BigValueLayout', () => {
['wide layout', {}],
['non-wide layout', { disableWideLayout: true }],
])('should shrink the value if percent change is shown for %s', (_, propsOverride) => {
const baseProps: Partial<Props> = {
const baseProps: Partial<BigValueProps> = {
width: 300,
height: 100,
sparkline: undefined,

View File

@@ -9,13 +9,15 @@ import { getTextColorForAlphaBackground } from '../../utils/colors';
import { calculateFontSize } from '../../utils/measureText';
import { Sparkline } from '../Sparkline/Sparkline';
import { BigValueColorMode, Props, BigValueJustifyMode, BigValueTextMode } from './BigValue';
import { BigValueColorMode, BigValueProps, BigValueJustifyMode, BigValueTextMode } from './BigValue';
import { percentChangeString } from './PercentChange';
const LINE_HEIGHT = 1.2;
const MAX_TITLE_SIZE = 30;
const VALUE_FONT_WEIGHT = 500;
export type BigValuePropsWithHeightAndWidth = BigValueProps & Required<Pick<BigValueProps, 'width' | 'height'>>;
export abstract class BigValueLayout {
titleFontSize: number;
valueFontSize: number;
@@ -31,7 +33,7 @@ export abstract class BigValueLayout {
maxTextHeight: number;
textValues: BigValueTextValues;
constructor(private props: Props) {
constructor(private props: BigValuePropsWithHeightAndWidth) {
const { width, height, value, text } = props;
this.valueColor = value.color ?? 'gray';
@@ -288,7 +290,7 @@ export abstract class BigValueLayout {
}
export class WideNoChartLayout extends BigValueLayout {
constructor(props: Props) {
constructor(props: BigValuePropsWithHeightAndWidth) {
super(props);
const valueWidthPercent = this.titleToAlignTo?.length ? 0.3 : 1.0;
@@ -350,7 +352,7 @@ export class WideNoChartLayout extends BigValueLayout {
}
export class WideWithChartLayout extends BigValueLayout {
constructor(props: Props) {
constructor(props: BigValuePropsWithHeightAndWidth) {
super(props);
const { width, height } = props;
@@ -406,7 +408,7 @@ export class WideWithChartLayout extends BigValueLayout {
}
export class StackedWithChartLayout extends BigValueLayout {
constructor(props: Props) {
constructor(props: BigValuePropsWithHeightAndWidth) {
super(props);
const { width, height } = props;
@@ -464,7 +466,7 @@ export class StackedWithChartLayout extends BigValueLayout {
}
export class StackedWithNoChartLayout extends BigValueLayout {
constructor(props: Props) {
constructor(props: BigValuePropsWithHeightAndWidth) {
super(props);
const { height } = props;
@@ -523,7 +525,7 @@ export class StackedWithNoChartLayout extends BigValueLayout {
}
}
export function buildLayout(props: Props): BigValueLayout {
export function buildLayout(props: BigValuePropsWithHeightAndWidth): BigValueLayout {
const { width, height, sparkline } = props;
const useWideLayout = width / height > 2.5 && !props.disableWideLayout;
@@ -557,7 +559,7 @@ export interface BigValueTextValues extends DisplayValue {
tooltip?: string;
}
function getTextValues(props: Props): BigValueTextValues {
function getTextValues(props: BigValuePropsWithHeightAndWidth): BigValueTextValues {
const { value, alignmentFactors, count } = props;
let { textMode } = props;

View File

@@ -1,7 +1,9 @@
import { css, cx } from '@emotion/css';
import { FloatingFocusManager, useFloating } from '@floating-ui/react';
import RcDrawer from '@rc-component/drawer';
import { ReactNode, useCallback, useEffect, useId, useState } from 'react';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
@@ -79,16 +81,17 @@ export function Drawer({
const styles = useStyles2(getStyles);
const wrapperStyles = useStyles2(getWrapperStyles, size);
const dragStyles = useStyles2(getDragStyles);
const titleId = useId();
const { context, refs } = useFloating({
open: true,
onOpenChange: (open) => {
if (!open) {
onClose?.();
}
const overlayRef = React.useRef(null);
const { dialogProps, titleProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
isDismissable: false,
isOpen: true,
onClose,
},
});
overlayRef
);
// Adds body class while open so the toolbar nav can hide some actions while drawer is open
useBodyClassWhileOpen();
@@ -114,8 +117,6 @@ export function Drawer({
minWidth,
},
}}
aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined}
aria-labelledby={typeof title !== 'string' ? titleId : undefined}
width={''}
motion={{
motionAppear: true,
@@ -128,8 +129,18 @@ export function Drawer({
motionName: styles.maskMotion,
}}
>
<FloatingFocusManager context={context} modal>
<div className={styles.container} ref={refs.setFloating}>
<FocusScope restoreFocus contain autoFocus>
<div
aria-label={
typeof title === 'string'
? selectors.components.Drawer.General.title(title)
: selectors.components.Drawer.General.title('no title')
}
className={styles.container}
{...overlayProps}
{...dialogProps}
ref={overlayRef}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={cx(dragStyles.dragHandleVertical, styles.resizer)}
@@ -148,7 +159,7 @@ export function Drawer({
</div>
{typeof title === 'string' ? (
<Stack direction="column">
<Text element="h3" truncate>
<Text element="h3" truncate {...titleProps}>
{title}
</Text>
{subtitle && (
@@ -158,13 +169,13 @@ export function Drawer({
)}
</Stack>
) : (
<div id={titleId}>{title}</div>
title
)}
{tabs && <div className={styles.tabsWrapper}>{tabs}</div>}
</div>
{!scrollableContent ? content : <ScrollContainer showScrollIndicators>{content}</ScrollContainer>}
</div>
</FloatingFocusManager>
</FocusScope>
</RcDrawer>
);
}

View File

@@ -1,7 +1,9 @@
import { cx } from '@emotion/css';
import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react';
import { OverlayContainer } from '@react-aria/overlays';
import { PropsWithChildren, ReactNode, useId, type JSX } from 'react';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import { PropsWithChildren, useRef, type JSX } from 'react';
import * as React from 'react';
import { t } from '@grafana/i18n';
@@ -64,26 +66,23 @@ export function Modal(props: PropsWithChildren<Props>) {
trapFocus = true,
} = props;
const styles = useStyles2(getModalStyles);
const titleId = useId();
const { context, refs } = useFloating({
open: isOpen,
onOpenChange: (open) => {
if (!open) {
onDismiss?.();
}
const ref = useRef<HTMLDivElement>(null);
// Handle interacting outside the dialog and pressing
// the Escape key to close the modal.
const { overlayProps, underlayProps } = useOverlay(
{ isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss },
ref
);
// Get props for the dialog and its title
const { dialogProps, titleProps } = useDialog(
{
'aria-label': ariaLabel,
},
});
const dismiss = useDismiss(context, {
enabled: closeOnEscape,
});
const role = useRole(context, {
role: 'dialog',
});
const { getFloatingProps } = useInteractions([dismiss, role]);
ref
);
if (!isOpen) {
return null;
@@ -97,17 +96,12 @@ export function Modal(props: PropsWithChildren<Props>) {
role="presentation"
className={styles.modalBackdrop}
onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)}
{...underlayProps}
/>
<FloatingFocusManager context={context} modal={trapFocus}>
<div
className={cx(styles.modal, className)}
ref={refs.setFloating}
aria-label={ariaLabel}
aria-labelledby={typeof title === 'string' ? titleId : undefined}
{...getFloatingProps()}
>
<FocusScope contain={trapFocus} autoFocus restoreFocus>
<div className={cx(styles.modal, className)} ref={ref} {...overlayProps} {...dialogProps}>
<div className={headerClass}>
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleId} />}
{typeof title === 'string' && <DefaultModalHeader {...props} title={title} id={titleProps.id} />}
{
// FIXME: custom title components won't get an accessible title.
// Do we really want to support them or shall we just limit this ModalTabsHeader?
@@ -124,12 +118,12 @@ export function Modal(props: PropsWithChildren<Props>) {
</div>
<div className={cx(styles.modalContent, contentClassName)}>{children}</div>
</div>
</FloatingFocusManager>
</FocusScope>
</OverlayContainer>
);
}
function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) {
function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) {
const styles = useStyles2(getModalStyles);
if (leftItems) {

View File

@@ -75,3 +75,5 @@ return (
</Toggletip>
);
```
<ArgTypes of={Toggletip} />

View File

@@ -1,11 +1,6 @@
import { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../Button/Button';
import { Drawer } from '../Drawer/Drawer';
import { Field } from '../Forms/Field';
import { Input } from '../Input/Input';
import { Modal } from '../Modal/Modal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import mdx from '../Toggletip/Toggletip.mdx';
@@ -138,86 +133,4 @@ LongContent.parameters = {
},
};
export const InsideDrawer: StoryFn<typeof Toggletip> = () => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<>
<Button onClick={() => setIsDrawerOpen(true)}>Open Drawer</Button>
{isDrawerOpen && (
<Drawer title="Drawer with Toggletip" onClose={() => setIsDrawerOpen(false)}>
<div>
<p style={{ marginBottom: '16px' }}>This demonstrates using Toggletip inside a Drawer.</p>
<Toggletip
title="Interactive Form"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Field label="Name">
<Input placeholder="Enter your name" />
</Field>
<Button variant="primary" size="sm">
Submit
</Button>
</div>
}
footer="Focus should work correctly within this Toggletip"
placement="bottom-start"
>
<Button>Click to show Toggletip</Button>
</Toggletip>
</div>
</Drawer>
)}
</>
);
};
InsideDrawer.parameters = {
controls: {
hideNoControlsWarning: true,
exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'],
},
};
export const InsideModal: StoryFn<typeof Toggletip> = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Button onClick={() => setIsModalOpen(true)}>Open Modal</Button>
<Modal title="Modal with Toggletip" isOpen={isModalOpen} onDismiss={() => setIsModalOpen(false)}>
<div>
<p style={{ marginBottom: '16px' }}>This demonstrates using Toggletip inside a Modal.</p>
<Modal.ButtonRow>
<Toggletip
title="Interactive Form"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Field label="Name">
<Input placeholder="Enter your name" />
</Field>
<Button variant="primary" size="sm">
Submit
</Button>
</div>
}
footer="Focus should work correctly within this Toggletip"
placement="bottom-start"
>
<Button>Click to show Toggletip</Button>
</Toggletip>
</Modal.ButtonRow>
</div>
</Modal>
</>
);
};
InsideDrawer.parameters = {
controls: {
hideNoControlsWarning: true,
exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'],
},
};
export default meta;

View File

@@ -23,7 +23,7 @@ export interface UsersIndicatorProps {
* https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs
*/
export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => {
const styles = useStyles2(getStyles, limit);
const styles = useStyles2(getStyles);
if (!users.length) {
return null;
}
@@ -39,9 +39,6 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp
className={styles.container}
aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')}
>
{users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => (
<UserIcon key={userView.user.name} userView={userView} />
))}
{limitReached && (
<UserIcon onClick={onClick} userView={{ user: { name: 'Extra users' } }} showTooltip={false}>
{tooManyUsers
@@ -50,30 +47,26 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp
: `+${extraUsers}`}
</UserIcon>
)}
{users
.slice(0, limitReached ? limit : limit + 1)
.reverse()
.map((userView) => (
<UserIcon key={userView.user.name} userView={userView} />
))}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, limit: number) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
justifyContent: 'center',
flexDirection: 'row-reverse',
marginLeft: theme.spacing(1),
isolation: 'isolate',
'& > button': {
marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other
// Ensure overlaying user icons are stacked correctly with z-index on each element
...Object.fromEntries(
Array.from({ length: limit }).map((_, idx) => [
`&:nth-of-type(${idx + 1})`,
{
zIndex: limit - idx,
},
])
),
},
}),
dots: css({

View File

@@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
table: css({
width: '100%',
'th:first-child': {
width: '100%',
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),

View File

@@ -134,7 +134,6 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'LegendLabelCell',
maxWidth: 0,
width: '100%',
minWidth: theme.spacing(16),
}),
labelCellInner: css({
label: 'LegendLabelCellInner',

View File

@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
)
var (
@@ -24,7 +25,7 @@ var (
)
type ReceiverService interface {
GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error)
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
@@ -115,12 +116,22 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, err
}
name, err := legacy_storage.UidToName(uid)
if err != nil {
return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid)
}
q := ngmodels.GetReceiverQuery{
OrgID: info.OrgID,
Name: name,
Decrypt: false,
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
r, err := s.service.GetReceiver(ctx, uid, false, user)
r, err := s.service.GetReceiver(ctx, q, user)
if err != nil {
return nil, err
}

View File

@@ -1,5 +1,5 @@
{
"basePath": "/api/v2/",
"basePath": "/api",
"consumes": [
"application/json"
],
@@ -2849,16 +2849,6 @@
"PermissionDenied": {
"type": "object"
},
"PostSilencesOKBody": {
"description": "PostSilencesOKBody post silences o k body",
"properties": {
"silenceID": {
"description": "silence ID",
"type": "string"
}
},
"type": "object"
},
"PostableApiAlertingConfig": {
"description": "nolint:revive",
"properties": {
@@ -4879,7 +4869,6 @@
"type": "object"
},
"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\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.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4915,7 +4904,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "A URL represents a parsed URL (technically, a URI reference).",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"type": "object"
},
"UpdateNamespaceRulesRequest": {
@@ -5715,15 +5704,10 @@
"type": "object"
}
},
"host": "localhost",
"info": {
"description": "API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager)\nSchemes:\nhttp",
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"title": "Alertmanager API",
"version": "0.0.1"
"description": "Package definitions includes the types required for generating or consuming an OpenAPI\nspec for the Grafana Alerting API.",
"title": "Grafana Alerting API.",
"version": "1.1.0"
},
"paths": {
"/convert/api/prom/rules": {
@@ -7513,204 +7497,6 @@
"type": "array"
}
},
"deleteSilenceInternalServerError": {
"description": "DeleteSilenceInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"deleteSilenceNotFound": {
"description": "DeleteSilenceNotFound A silence with the specified ID was not found"
},
"deleteSilenceOK": {
"description": "DeleteSilenceOK Delete silence response"
},
"getAlertGroupsBadRequest": {
"description": "GetAlertGroupsBadRequest Bad request",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getAlertGroupsInternalServerError": {
"description": "GetAlertGroupsInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getAlertGroupsOK": {
"description": "GetAlertGroupsOK Get alert groups response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/alertGroups"
}
},
"getAlertsBadRequest": {
"description": "GetAlertsBadRequest Bad request",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getAlertsInternalServerError": {
"description": "GetAlertsInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getAlertsOK": {
"description": "GetAlertsOK Get alerts response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/gettableAlerts"
}
},
"getReceiversOK": {
"description": "GetReceiversOK Get receivers response",
"headers": {
"body": {
"description": "In: Body",
"items": {
"$ref": "#/definitions/receiver"
},
"type": "array"
}
}
},
"getSilenceInternalServerError": {
"description": "GetSilenceInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getSilenceNotFound": {
"description": "GetSilenceNotFound A silence with the specified ID was not found"
},
"getSilenceOK": {
"description": "GetSilenceOK Get silence response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/gettableSilence"
}
},
"getSilencesBadRequest": {
"description": "GetSilencesBadRequest Bad request",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getSilencesInternalServerError": {
"description": "GetSilencesInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"getSilencesOK": {
"description": "GetSilencesOK Get silences response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/gettableSilences"
}
},
"getStatusOK": {
"description": "GetStatusOK Get status response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/alertmanagerStatus"
}
},
"postAlertsBadRequest": {
"description": "PostAlertsBadRequest Bad request",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"postAlertsInternalServerError": {
"description": "PostAlertsInternalServerError Internal server error",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"postAlertsOK": {
"description": "PostAlertsOK Create alerts response"
},
"postSilencesBadRequest": {
"description": "PostSilencesBadRequest Bad request",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"postSilencesNotFound": {
"description": "PostSilencesNotFound A silence with the specified ID was not found",
"headers": {
"body": {
"description": "In: Body",
"type": "string"
}
}
},
"postSilencesOK": {
"description": "PostSilencesOK Create / update silence response",
"headers": {
"body": {
"description": "In: Body"
}
},
"schema": {
"$ref": "#/definitions/PostSilencesOKBody"
}
},
"receiversResponse": {
"description": "",
"schema": {

View File

@@ -11,16 +11,11 @@
],
"swagger": "2.0",
"info": {
"description": "API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager)\nSchemes:\nhttp",
"title": "Alertmanager API",
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "0.0.1"
"description": "Package definitions includes the types required for generating or consuming an OpenAPI\nspec for the Grafana Alerting API.",
"title": "Grafana Alerting API.",
"version": "1.1.0"
},
"host": "localhost",
"basePath": "/api/v2/",
"basePath": "/api",
"paths": {
"/alertmanager/grafana/api/v2/alerts": {
"get": {
@@ -1075,127 +1070,6 @@
}
}
},
"/alerts": {
"get": {
"description": "Get a list of alerts",
"tags": [
"alert"
],
"operationId": "getAlerts",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "Show active alerts",
"name": "Active",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "A list of matchers to filter alerts by",
"name": "Filter",
"in": "query"
},
{
"type": "boolean",
"default": true,
"description": "Show inhibited alerts",
"name": "Inhibited",
"in": "query"
},
{
"type": "string",
"description": "A regex matching receivers to filter alerts by",
"name": "Receiver",
"in": "query"
},
{
"type": "boolean",
"default": true,
"description": "Show silenced alerts",
"name": "Silenced",
"in": "query"
},
{
"type": "boolean",
"default": true,
"description": "Show unprocessed alerts",
"name": "Unprocessed",
"in": "query"
}
]
},
"post": {
"description": "Create new Alerts",
"tags": [
"alert"
],
"operationId": "postAlerts",
"parameters": [
{
"description": "The alerts to create",
"name": "Alerts",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/postableAlerts"
}
}
]
}
},
"/alerts/groups": {
"get": {
"description": "Get a list of alert groups",
"tags": [
"alertgroup"
],
"operationId": "getAlertGroups",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "Show active alerts",
"name": "Active",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "A list of matchers to filter alerts by",
"name": "Filter",
"in": "query"
},
{
"type": "boolean",
"default": true,
"description": "Show inhibited alerts",
"name": "Inhibited",
"in": "query"
},
{
"type": "string",
"description": "A regex matching receivers to filter alerts by",
"name": "Receiver",
"in": "query"
},
{
"type": "boolean",
"default": true,
"description": "Show silenced alerts",
"name": "Silenced",
"in": "query"
}
]
}
},
"/convert/api/prom/rules": {
"get": {
"produces": [
@@ -2158,15 +2032,6 @@
}
}
},
"/receivers": {
"get": {
"description": "Get list of all receivers (name of notification integrations)",
"tags": [
"receiver"
],
"operationId": "getReceivers"
}
},
"/ruler/grafana/api/v1/export/rules": {
"get": {
"description": "List rules in provisioning format",
@@ -2995,90 +2860,6 @@
}
}
},
"/silence/{silenceID}": {
"get": {
"description": "Get a silence by its ID",
"tags": [
"silence"
],
"operationId": "getSilence",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "ID of the silence to get",
"name": "SilenceID",
"in": "path",
"required": true
}
]
},
"delete": {
"description": "Delete a silence by its ID",
"tags": [
"silence"
],
"operationId": "deleteSilence",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "ID of the silence to get",
"name": "SilenceID",
"in": "path",
"required": true
}
]
}
},
"/silences": {
"get": {
"description": "Get a list of silences",
"tags": [
"silence"
],
"operationId": "getSilences",
"parameters": [
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "A list of matchers to filter silences by",
"name": "Filter",
"in": "query"
}
]
},
"post": {
"description": "Post a new silence or update an existing one",
"tags": [
"silence"
],
"operationId": "postSilences",
"parameters": [
{
"description": "The silence to create",
"name": "Silence",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/postableSilence"
}
}
]
}
},
"/status": {
"get": {
"description": "Get current status of an Alertmanager instance and its cluster",
"tags": [
"general"
],
"operationId": "getStatus"
}
},
"/v1/eval": {
"post": {
"description": "Test rule",
@@ -7494,16 +7275,6 @@
"PermissionDenied": {
"type": "object"
},
"PostSilencesOKBody": {
"description": "PostSilencesOKBody post silences o k body",
"type": "object",
"properties": {
"silenceID": {
"description": "silence ID",
"type": "string"
}
}
},
"PostableApiAlertingConfig": {
"description": "nolint:revive",
"type": "object",
@@ -9524,9 +9295,8 @@
}
},
"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\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).",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -10385,204 +10155,6 @@
}
}
},
"deleteSilenceInternalServerError": {
"description": "DeleteSilenceInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"deleteSilenceNotFound": {
"description": "DeleteSilenceNotFound A silence with the specified ID was not found"
},
"deleteSilenceOK": {
"description": "DeleteSilenceOK Delete silence response"
},
"getAlertGroupsBadRequest": {
"description": "GetAlertGroupsBadRequest Bad request",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getAlertGroupsInternalServerError": {
"description": "GetAlertGroupsInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getAlertGroupsOK": {
"description": "GetAlertGroupsOK Get alert groups response",
"schema": {
"$ref": "#/definitions/alertGroups"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"getAlertsBadRequest": {
"description": "GetAlertsBadRequest Bad request",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getAlertsInternalServerError": {
"description": "GetAlertsInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getAlertsOK": {
"description": "GetAlertsOK Get alerts response",
"schema": {
"$ref": "#/definitions/gettableAlerts"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"getReceiversOK": {
"description": "GetReceiversOK Get receivers response",
"headers": {
"body": {
"type": "array",
"items": {
"$ref": "#/definitions/receiver"
},
"description": "In: Body"
}
}
},
"getSilenceInternalServerError": {
"description": "GetSilenceInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getSilenceNotFound": {
"description": "GetSilenceNotFound A silence with the specified ID was not found"
},
"getSilenceOK": {
"description": "GetSilenceOK Get silence response",
"schema": {
"$ref": "#/definitions/gettableSilence"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"getSilencesBadRequest": {
"description": "GetSilencesBadRequest Bad request",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getSilencesInternalServerError": {
"description": "GetSilencesInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"getSilencesOK": {
"description": "GetSilencesOK Get silences response",
"schema": {
"$ref": "#/definitions/gettableSilences"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"getStatusOK": {
"description": "GetStatusOK Get status response",
"schema": {
"$ref": "#/definitions/alertmanagerStatus"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"postAlertsBadRequest": {
"description": "PostAlertsBadRequest Bad request",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"postAlertsInternalServerError": {
"description": "PostAlertsInternalServerError Internal server error",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"postAlertsOK": {
"description": "PostAlertsOK Create alerts response"
},
"postSilencesBadRequest": {
"description": "PostSilencesBadRequest Bad request",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"postSilencesNotFound": {
"description": "PostSilencesNotFound A silence with the specified ID was not found",
"headers": {
"body": {
"type": "string",
"description": "In: Body"
}
}
},
"postSilencesOK": {
"description": "PostSilencesOK Create / update silence response",
"schema": {
"$ref": "#/definitions/PostSilencesOKBody"
},
"headers": {
"body": {
"description": "In: Body"
}
}
},
"receiversResponse": {
"description": "",
"schema": {

View File

@@ -16,6 +16,13 @@ import (
"github.com/grafana/alerting/receivers/schema"
)
// GetReceiverQuery represents a query for a single receiver.
type GetReceiverQuery struct {
OrgID int64
Name string
Decrypt bool
}
// GetReceiversQuery represents a query for receiver groups.
type GetReceiversQuery struct {
OrgID int64

View File

@@ -378,29 +378,3 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn
}
return encrypted, nil
}
// DecryptIntegrationSettings returns a function to decrypt integration settings.
func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := ss.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// EncryptIntegrationSettings returns a function to encrypt integration settings.
func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn {
return func(payload string) (string, error) {
encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
}

View File

@@ -1,37 +1,9 @@
package notifier
import (
"errors"
"slices"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
)
import "github.com/grafana/grafana/pkg/apimachinery/errutil"
// WithPublicError sets the public message of an errutil error to the error message.
func WithPublicError(err errutil.Error) error {
err.PublicMessage = err.Error()
return err
}
// If provided error is errutil.Error it appends fields that caused the error to public payload
func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error {
var authzErr errutil.Error
if !errors.As(err, &authzErr) {
return err
}
if authzErr.PublicPayload == nil {
authzErr.PublicPayload = map[string]interface{}{}
}
fields := make(map[string][]string, len(diff))
for field, paths := range diff {
fields[field] = make([]string, len(paths))
for i, path := range paths {
fields[field][i] = path.String()
}
slices.Sort(fields[field])
}
authzErr.PublicPayload["changed_protected_fields"] = fields
return authzErr
}

View File

@@ -2,6 +2,7 @@ package notifier
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
@@ -132,33 +133,30 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma
return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType())
}
// GetReceiver returns a receiver by its UID.
// GetReceiver returns a receiver by name.
// The receiver's secure settings are decrypted if requested and the user has access to do so.
func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) {
if user == nil {
return nil, errors.New("user is required")
}
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) {
ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes(
attribute.Int64("query_org_id", user.GetOrgID()),
attribute.String("query_uid", uid),
attribute.Bool("query_decrypt", decrypt),
attribute.Int64("query_org_id", q.OrgID),
attribute.String("query_name", q.Name),
attribute.Bool("query_decrypt", q.Decrypt),
))
defer span.End()
revision, err := rs.cfgStore.Get(ctx, user.GetOrgID())
revision, err := rs.cfgStore.Get(ctx, q.OrgID)
if err != nil {
return nil, err
}
prov, err := rs.loadProvenances(ctx, user.GetOrgID())
prov, err := rs.loadProvenances(ctx, q.OrgID)
if err != nil {
return nil, err
}
rcv, err := revision.GetReceiver(uid, prov)
rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov)
if err != nil {
if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported {
imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision)
imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision)
if len(imported) > 0 {
rcv = imported[0]
}
@@ -173,14 +171,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt
))
auth := rs.authz.AuthorizeReadDecrypted
if !decrypt {
if !q.Decrypt {
auth = rs.authz.AuthorizeRead
}
if err := auth(ctx, user, rcv); err != nil {
return nil, err
}
if decrypt {
if q.Decrypt {
err := rcv.Decrypt(rs.decryptor(ctx))
if err != nil {
rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err)
@@ -686,12 +684,28 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i
// decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn {
return DecryptIntegrationSettings(ctx, rs.encryptionService)
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn {
return EncryptIntegrationSettings(ctx, rs.encryptionService)
return func(payload string) (string, error) {
s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(s), nil
}
}
// checkOptimisticConcurrency checks if the existing receiver's version matches the desired version.

View File

@@ -0,0 +1,30 @@
package notifier
import (
"errors"
"slices"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
)
func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error {
var authzErr errutil.Error
if !errors.As(err, &authzErr) {
return err
}
if authzErr.PublicPayload == nil {
authzErr.PublicPayload = map[string]interface{}{}
}
fields := make(map[string][]string, len(diff))
for field, paths := range diff {
fields[field] = make([]string, len(paths))
for i, path := range paths {
fields[field][i] = path.String()
}
slices.Sort(fields[field])
}
authzErr.PublicPayload["changed_protected_fields"] = fields
return authzErr
}

View File

@@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("service gets receiver from AM config", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser)
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
require.NoError(t, err)
require.Equal(t, "slack receiver", recv.Name)
require.Len(t, recv.Integrations, 1)
@@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("service returns error when receiver does not exist", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
_, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
})
@@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("gets imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded)
recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.NoError(t, err)
assert.Equal(t, models.ResourceOriginImported, recv.Origin)
assert.Equal(t, "receiver1", recv.Name)
@@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig)
_, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
_, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser)
_, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
require.NoError(t, err)
})
})
@@ -187,6 +187,12 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
user: readUser,
err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver",
},
{
name: "service returns error if user is nil and decrypt is true",
decrypt: true,
user: nil,
err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver",
},
{
name: "service decrypts receivers with permission",
decrypt: true,
@@ -218,16 +224,18 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
},
}
for _, method := range getMethods {
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
for _, o := range origin {
for _, o := range origin {
for _, method := range getMethods {
t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) {
t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, o.opts...)
var res *models.Receiver
var err error
if method == "single" {
res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user)
q := singleQ(1, o.receiver)
q.Decrypt = tc.decrypt
res, err = sut.GetReceiver(context.Background(), q, tc.user)
} else {
q := multiQ(1, o.receiver)
q.Decrypt = tc.decrypt
@@ -259,8 +267,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) {
require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey])
}
})
}
})
})
}
}
}
}
@@ -404,7 +412,8 @@ func TestReceiverService_Delete(t *testing.T) {
// Ensure receiver saved to store is correct.
name, err := legacy_storage.UidToName(tc.deleteUID)
require.NoError(t, err)
_, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer)
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name}
_, err = sut.GetReceiver(context.Background(), q, writer)
assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType())
@@ -617,7 +626,8 @@ func TestReceiverService_Create(t *testing.T) {
assert.Equal(t, tc.expectedCreate, *created)
// Ensure receiver saved to store is correct.
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
require.NoError(t, err)
decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption.
@@ -921,7 +931,8 @@ func TestReceiverService_Update(t *testing.T) {
assert.Equal(t, tc.expectedUpdate, *updated)
// Ensure receiver saved to store is correct.
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
require.NoError(t, err)
decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption.
@@ -1043,7 +1054,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
sut.ruleNotificationsStore = ruleStore
newReceiverName = "receiver1"
actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer)
actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
require.NoError(t, err)
require.Equal(t, models.ResourceOriginImported, actual.Origin)
require.Equal(t, newReceiverName, actual.Name)
@@ -1056,7 +1067,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
require.NotEqual(t, actual, recv)
require.Equal(t, models.ResourceOriginGrafana, recv.Origin)
actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer)
actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer)
require.NoError(t, err)
require.Equal(t, recv.Name, actual.Name)
})
@@ -1174,7 +1185,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
return false
}
for _, recv := range allReceivers() {
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
if isVisible(recv.UID) {
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
assert.NotNil(t, response)
@@ -1196,7 +1207,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
}
sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true)
for _, recv := range allReceivers() {
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
if isVisibleInProvisioning(recv.UID) {
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
assert.NotNil(t, response)
@@ -1755,7 +1766,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) {
},
}}
r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin)
r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin)
require.NoError(t, err)
t.Run("should override metadata for imported receivers", func(t *testing.T) {
@@ -1831,6 +1842,13 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi
return string(bytes)
}
func singleQ(orgID int64, name string) models.GetReceiverQuery {
return models.GetReceiverQuery{
OrgID: orgID,
Name: name,
}
}
func multiQ(orgID int64, names ...string) models.GetReceiversQuery {
return models.GetReceiversQuery{
OrgID: orgID,

View File

@@ -40,7 +40,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client"
"github.com/grafana/grafana/pkg/services/ngalert/sender"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util/cmputil"
)
@@ -58,7 +57,6 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab
}
type Crypto interface {
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error
}
@@ -291,6 +289,20 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool {
return configHash == am.defaultConfigHash
}
func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := crypto.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use.
// It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs.
func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) {
@@ -305,7 +317,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea
}
// Decrypt the receivers in the configuration.
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto))
if err != nil {
return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err)
}
@@ -607,7 +619,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver,
}
func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) {
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto))
if err != nil {
return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err)
}

View File

@@ -43,6 +43,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/remote/client"
ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@@ -297,7 +298,13 @@ func TestIntegrationApplyConfig(t *testing.T) {
var c apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c))
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t)))
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
c.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
@@ -455,7 +462,13 @@ func TestCompareAndSendConfiguration(t *testing.T) {
// Create a config with correctly encrypted and encoded secrets.
var inputCfg apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg)
@@ -650,7 +663,13 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) {
var inputCfg apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
@@ -1018,7 +1037,13 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
{
postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret))
require.NoError(t, err)
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)

View File

@@ -151,7 +151,7 @@ type OrgUserDTO struct {
Role string `json:"role"`
LastSeenAt time.Time `json:"lastSeenAt"`
Updated time.Time `json:"-"`
Created time.Time `json:"created"`
Created time.Time `json:"-"`
LastSeenAtAge string `json:"lastSeenAtAge"`
AccessControl map[string]bool `json:"accessControl,omitempty"`
IsDisabled bool `json:"isDisabled"`

View File

@@ -146,7 +146,6 @@ type UserSearchHitDTO struct {
LastSeenAtAge string `json:"lastSeenAtAge"`
AuthLabels []string `json:"authLabels"`
AuthModule AuthModuleConversion `json:"-"`
Created time.Time `json:"created" xorm:"created"`
}
type GetUserProfileQuery struct {

View File

@@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (*
sess.Limit(query.Limit, offset)
}
sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created")
sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned")
if len(query.SortOpts) > 0 {
for i := range query.SortOpts {

View File

@@ -2,12 +2,9 @@ package test
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/bwmarrin/snowflake"
"github.com/stretchr/testify/require"
@@ -70,8 +67,6 @@ func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBac
}{
{"key_path generation", runTestIntegrationBackendKeyPathGeneration},
{"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility},
{"cross backend consistency", runTestCrossBackendConsistency},
{"concurrent operations stress", runTestConcurrentOperationsStress},
}
for _, tc := range cases {
@@ -116,25 +111,38 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
// Create 3 resources
for i := 1; i <= 3; i++ {
folder := ""
if i == 2 {
folder = "test-folder" // Resource 2 has folder annotation
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: nsPrefix,
Name: fmt.Sprintf("test-playlist-%d", i),
}
opts := PlaylistResourceOptions{
Name: fmt.Sprintf("test-playlist-%d", i),
Namespace: nsPrefix,
UID: fmt.Sprintf("test-uid-%d", i),
Generation: 1,
Title: fmt.Sprintf("My Test Playlist %d", i),
Folder: folder,
}
// Create resource JSON with folder annotation for resource 2
resourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "test-playlist-%d",
"namespace": "%s",
"uid": "test-uid-%d"%s
},
"spec": {
"title": "My Test Playlist %d"
}
}`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i)
created := createPlaylistResource(t, server, ctx, opts)
// Create the resource using server.Create
created, err := server.Create(ctx, &resourcepb.CreateRequest{
Key: key,
Value: []byte(resourceJSON),
})
require.NoError(t, err)
require.Nil(t, created.Error)
require.Greater(t, created.ResourceVersion, int64(0))
currentRVs[i-1] = created.ResourceVersion
// Verify created resource key_path (with folder for resource 2)
key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i))
if i == 2 {
verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder")
} else {
@@ -144,25 +152,39 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
// Update the 3 resources
for i := 1; i <= 3; i++ {
folder := ""
if i == 2 {
folder = "test-folder" // Resource 2 has folder annotation
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: nsPrefix,
Name: fmt.Sprintf("test-playlist-%d", i),
}
opts := PlaylistResourceOptions{
Name: fmt.Sprintf("test-playlist-%d", i),
Namespace: nsPrefix,
UID: fmt.Sprintf("test-uid-%d", i),
Generation: 2,
Title: fmt.Sprintf("My Updated Playlist %d", i),
Folder: folder,
}
// Create updated resource JSON with folder annotation for resource 2
updatedResourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "test-playlist-%d",
"namespace": "%s",
"uid": "test-uid-%d"%s
},
"spec": {
"title": "My Updated Playlist %d"
}
}`, i, nsPrefix, i, getAnnotationsJSON(i == 2), i)
updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1])
// Update the resource using server.Update
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
Key: key,
Value: []byte(updatedResourceJSON),
ResourceVersion: currentRVs[i-1], // Use the resource version returned by previous operation
})
require.NoError(t, err)
require.Nil(t, updated.Error)
require.Greater(t, updated.ResourceVersion, currentRVs[i-1])
currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version
// Verify updated resource key_path (with folder for resource 2)
key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i))
if i == 2 {
verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder")
} else {
@@ -172,11 +194,22 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
// Delete the 3 resources
for i := 1; i <= 3; i++ {
name := fmt.Sprintf("test-playlist-%d", i)
deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1])
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: nsPrefix,
Name: fmt.Sprintf("test-playlist-%d", i),
}
// Delete the resource using server.Delete
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
Key: key,
ResourceVersion: currentRVs[i-1], // Use the resource version from previous operation
})
require.NoError(t, err)
require.Greater(t, deleted.ResourceVersion, currentRVs[i-1])
// Verify deleted resource key_path (with folder for resource 2)
key := createPlaylistKey(nsPrefix, name)
if i == 2 {
verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder")
} else {
@@ -243,6 +276,17 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
require.Equal(t, expectedActionCode, actualAction)
}
// getAnnotationsJSON returns the annotations JSON string for the folder annotation if needed
func getAnnotationsJSON(withFolder bool) string {
if withFolder {
return `,
"annotations": {
"grafana.app/folder": "test-folder"
}`
}
return ""
}
// runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields
func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
ctx := testutil.NewDefaultTestContext(t)
@@ -305,35 +349,76 @@ func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, name
// Create 3 resources
for i, res := range resources {
// Create the resource using helper function
opts := PlaylistResourceOptions{
Name: res.name,
Namespace: namespace,
UID: fmt.Sprintf("test-uid-%d", i+1),
Generation: 1,
Title: fmt.Sprintf("Test Playlist %d", i+1),
Folder: res.folder,
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: res.name,
}
created := createPlaylistResource(t, server, ctx, opts)
// Create resource JSON with folder annotation and generation=1 for creates
resourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "%s",
"namespace": "%s",
"uid": "test-uid-%d",
"generation": 1%s
},
"spec": {
"title": "Test Playlist %d"
}
}`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1)
// Create the resource
created, err := server.Create(ctx, &resourcepb.CreateRequest{
Key: key,
Value: []byte(resourceJSON),
})
require.NoError(t, err)
require.Nil(t, created.Error)
require.Greater(t, created.ResourceVersion, int64(0))
// Store the resource version
resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion)
}
// Update 3 resources
for i, res := range resources {
// Update the resource using helper function
opts := PlaylistResourceOptions{
Name: res.name,
Namespace: namespace,
UID: fmt.Sprintf("test-uid-%d", i+1),
Generation: 2,
Title: fmt.Sprintf("Updated Test Playlist %d", i+1),
Folder: res.folder,
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: res.name,
}
// Update resource JSON with generation=2 for updates
resourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "%s",
"namespace": "%s",
"uid": "test-uid-%d",
"generation": 2%s
},
"spec": {
"title": "Updated Test Playlist %d"
}
}`, res.name, namespace, i+1, getAnnotationsJSON(res.folder != ""), i+1)
// Update the resource using the current resource version
currentRV := resourceVersions[i][len(resourceVersions[i])-1]
updated := updatePlaylistResource(t, server, ctx, opts, currentRV)
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
Key: key,
Value: []byte(resourceJSON),
ResourceVersion: currentRV,
})
require.NoError(t, err)
require.Nil(t, updated.Error)
require.Greater(t, updated.ResourceVersion, currentRV)
// Store the new resource version
resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion)
}
@@ -629,627 +714,3 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
// But it shouldn't be too much higher (within a reasonable range)
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
}
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
ctx := testutil.NewDefaultTestContext(t)
// Create storage servers from both backends
sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: sqlBackend,
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
})
require.NoError(t, err)
kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: kvBackend,
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
})
require.NoError(t, err)
// Create isolated namespaces for each test phase
sqlNamespace := nsPrefix + "-concurrent-sql"
kvNamespace := nsPrefix + "-concurrent-kv"
t.Run("Write to SQL, Read from Both", func(t *testing.T) {
runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql")
})
t.Run("Write to KV, Read from Both", func(t *testing.T) {
runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv")
})
t.Run("Resource Version Consistency", func(t *testing.T) {
runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx)
})
}
// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends
func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) {
// Skip on SQLite due to concurrency limitations
if db.DriverName() == "sqlite3" {
t.Skip("Skipping concurrent operations stress test on SQLite")
}
ctx := testutil.NewDefaultTestContext(t)
// Create storage servers from both backends
sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: sqlBackend,
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
})
require.NoError(t, err)
kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: kvBackend,
AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing
})
require.NoError(t, err)
// Create isolated namespace for mixed operations
mixedNamespace := nsPrefix + "-concurrent-mixed"
// do a single create using the sql backend to initialize the resource_version table
// without this, both backend may try to insert the same group+resource to the resource_version which breaks the
// tests
initNamespace := mixedNamespace + "-init"
initOpts := PlaylistResourceOptions{
Name: "init-resource",
Namespace: initNamespace,
UID: "init-uid",
Generation: 1,
Title: "Init Resource",
Folder: "",
}
createPlaylistResource(t, sqlServer, ctx, initOpts)
// Heavy Mixed Concurrent Operations
t.Run("Mixed Concurrent Operations", func(t *testing.T) {
runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx)
})
}
// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency
func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) {
// Create 5 test resources
resourceNames := []string{
fmt.Sprintf("resource-%s-1", writerBackend),
fmt.Sprintf("resource-%s-2", writerBackend),
fmt.Sprintf("resource-%s-3", writerBackend),
fmt.Sprintf("resource-%s-4", writerBackend),
fmt.Sprintf("resource-%s-5", writerBackend),
}
createdResourceVersions := make([]int64, len(resourceNames))
// Write all resources to the write backend
for i, resourceName := range resourceNames {
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: resourceName,
}
resourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "%s",
"namespace": "%s",
"uid": "test-uid-%d",
"generation": 1
},
"spec": {
"title": "Concurrent Test Playlist %d"
}
}`, resourceName, namespace, i+1, i+1)
created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{
Key: key,
Value: []byte(resourceJSON),
})
require.NoError(t, err)
require.Nil(t, created.Error)
require.Greater(t, created.ResourceVersion, int64(0))
createdResourceVersions[i] = created.ResourceVersion
}
// Add a small delay to ensure data propagates
time.Sleep(10 * time.Millisecond)
// Read from both backends and compare payloads
for _, resourceName := range resourceNames {
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: resourceName,
}
// Read from write backend
writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err, "Failed to read %s from write backend", resourceName)
require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error)
require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName)
// Read from read backend
readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err, "Failed to read %s from read backend", resourceName)
require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error)
require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName)
// Validate that both backends return identical payload content
require.JSONEq(t, string(writeResp.Value), string(readResp.Value),
"Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s",
resourceName, string(writeResp.Value), string(readResp.Value))
// Validate that both backends return equivalent resource versions using rvmanager compatibility check
// Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings
require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion),
"Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d",
resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion)
t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends",
resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend))
}
// Verify List consistency between backends
verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames))
}
// getOtherBackendName returns the complementary backend name
func getOtherBackendName(backend string) string {
if backend == "sql" {
return "kv"
}
return "sql"
}
// runMixedConcurrentOperations runs different operations simultaneously on both backends
func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) {
var wg sync.WaitGroup
errors := make(chan error, 20)
startBarrier := make(chan struct{})
// Use higher operation counts to ensure concurrency
opCounts := BackendOperationCounts{
Creates: 25,
Updates: 15,
Deletes: 10,
}
// SQL backend operations
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier // Wait for signal to start
if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil {
errors <- fmt.Errorf("SQL backend operations failed: %w", err)
}
}()
// KV backend operations
wg.Add(1)
go func() {
defer wg.Done()
<-startBarrier // Wait for signal to start
if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil {
errors <- fmt.Errorf("KV backend operations failed: %w", err)
}
}()
// Start both goroutines simultaneously
close(startBarrier)
// Wait for operations to complete with timeout
done := make(chan bool)
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Operations completed
case <-time.After(10 * time.Second):
t.Fatal("Timeout waiting for mixed concurrent operations")
}
// Check for errors
close(errors)
for err := range errors {
t.Error(err)
}
// Allow some time for data propagation
time.Sleep(50 * time.Millisecond)
// Calculate expected remaining resources based on operation counts
expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining
// Verify consistency of resources created by SQL backend operations
// Note: Skip resource version checking since these are separate operations on different backends
verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false)
// Verify consistency of resources created by KV backend operations
// Note: Skip resource version checking since these are separate operations on different backends
verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false)
}
// BackendOperationCounts defines how many operations of each type to perform
type BackendOperationCounts struct {
Creates int
Updates int
Deletes int
}
// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend
func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error {
// Create resources
resourceVersions := make([]int64, counts.Creates)
for i := 1; i <= counts.Creates; i++ {
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
}
resourceJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "resource-%s-%d",
"namespace": "%s",
"uid": "test-uid-%s-%d",
"generation": 1
},
"spec": {
"title": "Mixed Test Playlist %s %d"
}
}`, backendType, i, namespace, backendType, i, backendType, i)
created, err := server.Create(ctx, &resourcepb.CreateRequest{
Key: key,
Value: []byte(resourceJSON),
})
if err != nil {
return fmt.Errorf("failed to create resource %d: %w", i, err)
}
if created.Error != nil {
return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message)
}
resourceVersions[i-1] = created.ResourceVersion
}
// Update resources (only update as many as we have, limited by creates and updates count)
updateCount := counts.Updates
if updateCount > counts.Creates {
updateCount = counts.Creates // Can't update more resources than we created
}
for i := 1; i <= updateCount; i++ {
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
}
updatedJSON := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "resource-%s-%d",
"namespace": "%s",
"uid": "test-uid-%s-%d",
"generation": 2
},
"spec": {
"title": "Updated Mixed Test Playlist %s %d"
}
}`, backendType, i, namespace, backendType, i, backendType, i)
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
Key: key,
Value: []byte(updatedJSON),
ResourceVersion: resourceVersions[i-1],
})
if err != nil {
return fmt.Errorf("failed to update resource %d: %w", i, err)
}
if updated.Error != nil {
return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message)
}
resourceVersions[i-1] = updated.ResourceVersion
}
// Delete resources (only delete as many as we have, limited by creates and deletes count)
deleteCount := counts.Deletes
if deleteCount > updateCount {
deleteCount = updateCount // Can only delete resources that were updated (have latest RV)
}
for i := 1; i <= deleteCount; i++ {
key := &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: fmt.Sprintf("resource-%s-%d", backendType, i),
}
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
Key: key,
ResourceVersion: resourceVersions[i-1], // Use the resource version from updates
})
if err != nil {
return fmt.Errorf("failed to delete resource %d: %w", i, err)
}
if deleted.Error != nil {
return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message)
}
}
return nil
}
// runResourceVersionConsistencyTest verifies resource version handling across backends
func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) {
// Create a resource on SQL backend
opts := PlaylistResourceOptions{
Name: "rv-test-resource",
Namespace: namespace,
UID: "test-uid-rv",
Generation: 1,
Title: "RV Test Playlist",
Folder: "", // No folder
}
createPlaylistResource(t, sqlServer, ctx, opts)
// Allow data to propagate
time.Sleep(10 * time.Millisecond)
// Read from KV backend to get the same resource
key := createPlaylistKey(namespace, "rv-test-resource")
kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err)
require.Nil(t, kvRead.Error)
// Note: Resource versions may differ between backends, but content should be the same
require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version")
// Read from SQL backend to compare content
sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err)
require.Nil(t, sqlReadInitial.Error)
require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content")
// Update via KV backend
updateOpts := PlaylistResourceOptions{
Name: "rv-test-resource",
Namespace: namespace,
UID: "test-uid-rv",
Generation: 2,
Title: "Updated RV Test Playlist",
Folder: "", // No folder
}
updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion)
// Allow data to propagate
time.Sleep(10 * time.Millisecond)
// Read from SQL backend to verify consistency
sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err)
require.Nil(t, sqlRead.Error)
// Note: Resource versions may differ, but content should be consistent
require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version")
// Verify both backends return the same content - we need to read from KV again to get the Value
kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key})
require.NoError(t, err)
require.Nil(t, kvReadAfterUpdate.Error)
require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content")
}
// verifyListConsistencyBetweenServers verifies that both servers return consistent list results
func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) {
verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true)
}
// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking
func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) {
ctx := testutil.NewDefaultTestContext(t)
// Get lists from both servers
list1, err := server1.List(ctx, &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
},
},
})
require.NoError(t, err)
require.Nil(t, list1.Error)
list2, err := server2.List(ctx, &resourcepb.ListRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
},
},
})
require.NoError(t, err)
require.Nil(t, list2.Error)
// Create maps for easier comparison by extracting names from JSON
items1 := make(map[string]*resourcepb.ResourceWrapper)
for _, item := range list1.Items {
itemNamespace := extractResourceNamespaceFromJSON(t, item.Value)
if itemNamespace == namespace { // Only compare items from our exact namespace
name := extractResourceNameFromJSON(t, item.Value)
items1[name] = item
}
}
items2 := make(map[string]*resourcepb.ResourceWrapper)
for _, item := range list2.Items {
itemNamespace := extractResourceNamespaceFromJSON(t, item.Value)
if itemNamespace == namespace { // Only compare items from our exact namespace
name := extractResourceNameFromJSON(t, item.Value)
items2[name] = item
}
}
// Verify counts match after filtering by namespace
require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering")
require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering")
require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering")
// Verify all items exist in both lists with same content and resource version
for name, item1 := range items1 {
item2, exists := items2[name]
require.True(t, exists, "Item %s should exist in both lists", name)
require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name)
require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name)
require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name)
// Validate that both backends return equivalent resource versions using rvmanager compatibility check
if checkResourceVersions {
require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion),
"Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion)
}
}
}
// extractResourceNameFromJSON extracts the resource name from JSON metadata
func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string {
var obj map[string]interface{}
err := json.Unmarshal(jsonData, &obj)
require.NoError(t, err, "Failed to unmarshal JSON")
metadata, ok := obj["metadata"].(map[string]interface{})
require.True(t, ok, "metadata field not found or not an object")
name, ok := metadata["name"].(string)
require.True(t, ok, "name field not found or not a string")
return name
}
// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata
func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string {
var obj map[string]interface{}
err := json.Unmarshal(jsonData, &obj)
require.NoError(t, err, "Failed to unmarshal JSON")
metadata, ok := obj["metadata"].(map[string]interface{})
require.True(t, ok, "metadata field not found or not an object")
namespace, ok := metadata["namespace"].(string)
require.True(t, ok, "namespace field not found or not a string")
return namespace
}
// PlaylistResourceOptions defines options for creating test playlist resources
type PlaylistResourceOptions struct {
Name string
Namespace string
UID string
Generation int
Title string
Folder string // optional - empty string means no folder
}
// createPlaylistJSON creates standardized JSON for playlist resources
func createPlaylistJSON(opts PlaylistResourceOptions) []byte {
folderAnnotation := ""
if opts.Folder != "" {
folderAnnotation = fmt.Sprintf(`,
"annotations": {
"grafana.app/folder": "%s"
}`, opts.Folder)
}
jsonStr := fmt.Sprintf(`{
"apiVersion": "playlist.grafana.app/v0alpha1",
"kind": "Playlist",
"metadata": {
"name": "%s",
"namespace": "%s",
"uid": "%s",
"generation": %d%s
},
"spec": {
"title": "%s"
}
}`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title)
return []byte(jsonStr)
}
// createPlaylistKey creates standardized ResourceKey for playlist resources
func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey {
return &resourcepb.ResourceKey{
Group: "playlist.grafana.app",
Resource: "playlists",
Namespace: namespace,
Name: name,
}
}
// createPlaylistResource creates a playlist resource using the server with consistent error handling
func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse {
t.Helper()
key := createPlaylistKey(opts.Namespace, opts.Name)
value := createPlaylistJSON(opts)
created, err := server.Create(ctx, &resourcepb.CreateRequest{
Key: key,
Value: value,
})
require.NoError(t, err)
require.Nil(t, created.Error)
require.Greater(t, created.ResourceVersion, int64(0))
return created
}
// updatePlaylistResource updates a playlist resource using the server with consistent error handling
func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse {
t.Helper()
key := createPlaylistKey(opts.Namespace, opts.Name)
value := createPlaylistJSON(opts)
updated, err := server.Update(ctx, &resourcepb.UpdateRequest{
Key: key,
Value: value,
ResourceVersion: resourceVersion,
})
require.NoError(t, err)
require.Nil(t, updated.Error)
require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input
return updated
}
// deletePlaylistResource deletes a playlist resource using the server with consistent error handling
func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse {
t.Helper()
key := createPlaylistKey(namespace, name)
deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{
Key: key,
ResourceVersion: resourceVersion,
})
require.NoError(t, err)
require.Nil(t, deleted.Error)
require.Greater(t, deleted.ResourceVersion, int64(0))
return deleted
}

View File

@@ -6002,10 +6002,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
@@ -8907,10 +8903,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},

View File

@@ -18489,10 +18489,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},
@@ -23414,10 +23410,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"type": "string",
"format": "date-time"
},
"email": {
"type": "string"
},

View File

@@ -115,15 +115,12 @@ export const OrgUsersTable = ({
{
id: 'lastSeenAtAge',
header: 'Last active',
cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => {
// If lastSeenAt is before created, user has never logged in
const neverLoggedIn =
original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created);
cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => {
return (
<>
{value && (
<>
{neverLoggedIn ? (
{value === '10 years' ? (
<Text color={'disabled'}>
<Trans i18nKey="admin.org-uers.last-seen-never">Never</Trans>
</Text>

View File

@@ -135,19 +135,12 @@ export const UsersTable = ({
content: 'Time since user was seen using Grafana',
iconName: 'question-circle',
},
cell: ({
cell: { value },
row: {
original: { lastSeenAt, created },
},
}: Cell<'lastSeenAtAge'>) => {
// The user has never logged in if lastSeenAt is before its creation date.
const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created);
cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => {
return (
<>
{value && (
<>
{neverLoggedIn ? (
{value === '10 years' ? (
<Text color={'disabled'}>
<Trans i18nKey="admin.users-table.last-seen-never">Never</Trans>
</Text>

View File

@@ -79,9 +79,6 @@ describe('new receiver', () => {
// click test
await user.click(ui.testContactPoint.get());
// close the modal
await user.click(screen.getByRole('button', { name: 'Close' }));
// we shouldn't be testing implementation details but when the request is successful
// it can't seem to assert on the success toast
await user.click(ui.saveContactButton.get());

View File

@@ -71,7 +71,6 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.SELECT_PANEL_PLUGIN,
plugin_id: pluginId,
from_suggestions: options.fromSuggestions ?? false,
});
// clear custom options
@@ -237,7 +236,6 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
onClose={model.onToggleVizPicker}
data={data}
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
isNewPanel={isNewPanel}
/>
)}
</>

View File

@@ -26,7 +26,6 @@ export interface Props {
editPreview: VizPanel;
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
onClose: () => void;
isNewPanel?: boolean;
}
const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> => {
@@ -43,7 +42,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
: [allVisualizationsTab, suggestionsTab];
};
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton, isNewPanel }: Props) {
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) {
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const filterId = useId();
@@ -84,16 +83,6 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
[setListMode]
);
const handleBackButtonClick = useCallback(() => {
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.BACK_BUTTON,
tab: VisualizationSelectPaneTab[listMode],
creator_team: 'grafana_plugins_catalog',
schema_version: '1.0.0',
});
onClose();
}, [listMode, onClose]);
return (
<div className={styles.wrapper}>
<TabsBar className={styles.tabs} hideBorder={true}>
@@ -125,7 +114,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={handleBackButtonClick}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
@@ -147,7 +136,6 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose
editPreview={editPreview}
data={data}
searchQuery={searchQuery}
isNewPanel={isNewPanel}
/>
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (

View File

@@ -4,5 +4,4 @@ export const INTERACTION_ITEM = {
SELECT_PANEL_PLUGIN: 'select_panel_plugin',
CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker
SEARCH: 'search', // for ref - PanelVizTypePicker
BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker
};

View File

@@ -1,4 +1,4 @@
import { render, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils';
import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils';
import { ExpressionQuery, ExpressionQueryType } from '../../types';
@@ -72,12 +72,12 @@ describe('SqlExpr', () => {
const refIds = [{ value: 'A' }];
const query = { refId: 'expr1', type: 'sql', expression: '' } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
});
// Verify onChange was called
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
expect(onChange).toHaveBeenCalled();
// Verify essential SQL structure without exact string matching
const updatedQuery = onChange.mock.calls[0][0];
@@ -90,12 +90,19 @@ describe('SqlExpr', () => {
const existingExpression = 'SELECT 1 AS foo';
const query = { refId: 'expr1', type: 'sql', expression: existingExpression } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} queries={[]} />);
});
// Check if onChange was called
if (onChange.mock.calls.length > 0) {
// If called, ensure it didn't change the expression value
const updatedQuery = onChange.mock.calls[0][0];
expect(updatedQuery.expression).toBe(existingExpression);
}
// The SQLEditor should receive the existing expression
await waitFor(() => {
expect(query.expression).toBe(existingExpression);
});
expect(query.expression).toBe(existingExpression);
});
it('adds alerting format when alerting prop is true', async () => {
@@ -103,12 +110,40 @@ describe('SqlExpr', () => {
const refIds = [{ value: 'A' }];
const query = { refId: 'expr1', type: 'sql' } as ExpressionQuery;
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
await waitFor(() => {
const updatedQuery = onChange.mock.calls[0][0];
expect(updatedQuery.format).toBe('alerting');
await act(async () => {
render(<SqlExpr onChange={onChange} refIds={refIds} query={query} alerting queries={[]} />);
});
const updatedQuery = onChange.mock.calls[0][0];
expect(updatedQuery.format).toBe('alerting');
});
});
describe('SqlExpr with GenAI features', () => {
const defaultProps: SqlExprProps = {
onChange: jest.fn(),
refIds: [{ value: 'A' }],
query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` },
queries: [],
};
it('renders suggestions drawer when isDrawerOpen is true', async () => {
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
useSQLSuggestions.mockImplementation(() => ({
isDrawerOpen: true,
suggestions: ['suggestion1', 'suggestion2'],
}));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('suggestions-drawer')).toBeInTheDocument();
});
it('renders explanation drawer when isExplanationOpen is true', async () => {
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true }));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
});
});
@@ -131,10 +166,10 @@ describe('Schema Inspector feature toggle', () => {
});
});
it('renders panel open by default', async () => {
const { findByText } = render(<SqlExpr {...defaultProps} />);
it('renders panel open by default', () => {
const { getByText } = render(<SqlExpr {...defaultProps} />);
expect(await findByText('No schema information available')).toBeInTheDocument();
expect(getByText('No schema information available')).toBeInTheDocument();
});
it('closes panel and shows reopen button when close button clicked', async () => {
@@ -143,7 +178,7 @@ describe('Schema Inspector feature toggle', () => {
expect(queryByText('No schema information available')).toBeInTheDocument();
const closeButton = getByText('Schema inspector');
await userEvent.click(closeButton);
await act(async () => fireEvent.click(closeButton));
expect(queryByText('No schema information available')).not.toBeInTheDocument();
expect(await findByText('Schema inspector')).toBeInTheDocument();
@@ -153,12 +188,12 @@ describe('Schema Inspector feature toggle', () => {
const { queryByText, getByText } = render(<SqlExpr {...defaultProps} />);
const closeButton = getByText('Schema inspector');
await userEvent.click(closeButton);
await act(async () => fireEvent.click(closeButton));
expect(queryByText('No schema information available')).not.toBeInTheDocument();
const reopenButton = getByText('Schema inspector');
await userEvent.click(reopenButton);
await act(async () => fireEvent.click(reopenButton));
expect(queryByText('No schema information available')).toBeInTheDocument();
});
@@ -198,33 +233,3 @@ describe('Schema Inspector feature toggle', () => {
});
});
});
describe('SqlExpr with GenAI features', () => {
const defaultProps: SqlExprProps = {
onChange: jest.fn(),
refIds: [{ value: 'A' }],
query: { refId: 'expression_1', type: ExpressionQueryType.sql, expression: `SELECT * FROM A LIMIT 10` },
queries: [],
};
it('renders suggestions drawer when isDrawerOpen is true', async () => {
// TODO this inline require breaks future tests - do it differently!
const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions');
useSQLSuggestions.mockImplementation(() => ({
isDrawerOpen: true,
suggestions: ['suggestion1', 'suggestion2'],
}));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('suggestions-drawer')).toBeInTheDocument();
});
it('renders explanation drawer when isExplanationOpen is true', async () => {
// TODO this inline require breaks future tests - do it differently!
const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations');
useSQLExplanations.mockImplementation(() => ({ isExplanationOpen: true }));
const { findByTestId } = render(<SqlExpr {...defaultProps} />);
expect(await findByTestId('explanation-drawer')).toBeInTheDocument();
});
});

View File

@@ -340,16 +340,14 @@ describe('LibraryPanelsSearch', () => {
await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]);
await waitFor(() =>
expect(getLibraryPanelsSpy).toHaveBeenCalledWith(
expect.objectContaining({
searchString: '',
folderFilterUIDs: ['wfTJJL5Wz'],
page: 1,
typeFilter: [],
sortDirection: undefined,
perPage: 40,
})
)
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
searchString: '',
folderFilterUIDs: ['wfTJJL5Wz'],
page: 1,
typeFilter: [],
sortDirection: undefined,
perPage: 40,
})
);
});
});

View File

@@ -22,7 +22,6 @@ import { getAllSuggestions } from '../../suggestions/getAllSuggestions';
import { hasData } from '../../suggestions/utils';
import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
import { VizSuggestionsInteractions, PANEL_STATES, type PanelState } from './interactions';
import { VizTypeChangeDetails } from './types';
export interface Props {
@@ -31,7 +30,6 @@ export interface Props {
data?: PanelData;
panel?: PanelModel;
searchQuery?: string;
isNewPanel?: boolean;
}
const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => {
@@ -64,7 +62,7 @@ const useSuggestions = (data: PanelData | undefined, searchQuery: string | undef
return { value: filteredValue, loading, error, retry };
};
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery, isNewPanel }: Props) {
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) {
const styles = useStyles2(getStyles);
const { value: result, loading, error, retry } = useSuggestions(data, searchQuery);
@@ -77,18 +75,6 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions;
const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID;
const panelState = useMemo((): PanelState => {
if (isUnconfiguredPanel) {
return PANEL_STATES.UNCONFIGURED_PANEL;
}
if (isNewPanel) {
return PANEL_STATES.NEW_PANEL;
}
return PANEL_STATES.EXISTING_PANEL;
}, [isUnconfiguredPanel, isNewPanel]);
const suggestionsByVizType = useMemo(() => {
const meta = getAllPanelPluginMeta();
const record: Record<string, PanelPluginMeta> = {};
@@ -110,36 +96,22 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
}, [suggestions]);
const applySuggestion = useCallback(
(suggestion: PanelPluginVisualizationSuggestion, isPreview: boolean, isAutoSelected = false) => {
if (isPreview) {
VizSuggestionsInteractions.suggestionPreviewed({
pluginId: suggestion.pluginId,
suggestionName: suggestion.name,
panelState,
isAutoSelected,
});
setSuggestionHash(suggestion.hash);
} else {
VizSuggestionsInteractions.suggestionAccepted({
pluginId: suggestion.pluginId,
suggestionName: suggestion.name,
panelState,
});
}
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
onChange(
{
pluginId: suggestion.pluginId,
options: suggestion.options,
fieldConfig: suggestion.fieldConfig,
withModKey: isPreview,
fromSuggestions: true,
},
isPreview ? editPreview : undefined
);
if (isPreview) {
setSuggestionHash(suggestion.hash);
}
},
[onChange, editPreview, panelState]
[onChange, editPreview]
);
useEffect(() => {
@@ -152,7 +124,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
// the previously selected suggestion is no longer present in the list.
const newFirstCardHash = suggestions?.[0]?.hash ?? null;
if (firstCardHash !== newFirstCardHash || suggestions.every((s) => s.hash !== suggestionHash)) {
applySuggestion(suggestions[0], true, true);
applySuggestion(suggestions[0], true);
setFirstCardHash(newFirstCardHash);
return;
}
@@ -271,7 +243,7 @@ export function VisualizationSuggestions({ onChange, editPreview, data, panel, s
suggestion={suggestion}
width={width}
tabIndex={index}
onClick={() => applySuggestion(suggestion, false)}
onClick={() => applySuggestion(suggestion)}
/>
</div>
))}

View File

@@ -1,28 +0,0 @@
import { reportInteraction } from '@grafana/runtime';
export const PANEL_STATES = {
UNCONFIGURED_PANEL: 'unconfigured_panel',
NEW_PANEL: 'new_panel',
EXISTING_PANEL: 'existing_panel',
} as const;
export type PanelState = (typeof PANEL_STATES)[keyof typeof PANEL_STATES];
export const VizSuggestionsInteractions = {
suggestionPreviewed: (properties: {
pluginId: string;
suggestionName: string;
panelState: PanelState;
isAutoSelected?: boolean;
}) => {
reportVizSuggestionsInteraction('suggestion_previewed', properties);
},
suggestionAccepted: (properties: { pluginId: string; suggestionName: string; panelState: PanelState }) => {
reportVizSuggestionsInteraction('suggestion_accepted', properties);
},
};
const reportVizSuggestionsInteraction = (name: string, properties?: Record<string, unknown>) => {
reportInteraction(`grafana_viz_suggestions_${name}`, properties);
};

View File

@@ -5,5 +5,4 @@ export interface VizTypeChangeDetails {
options?: Record<string, unknown>;
fieldConfig?: FieldConfigSource;
withModKey?: boolean;
fromSuggestions?: boolean;
}

View File

@@ -5,7 +5,7 @@ import {
VisualizationSuggestion,
VisualizationSuggestionScore,
} from '@grafana/data';
import { LegendDisplayMode, ReduceDataOptions, VizLegendOptions } from '@grafana/schema';
import { ReduceDataOptions } from '@grafana/schema';
/**
* @internal
@@ -62,15 +62,3 @@ export function defaultNumericVizOptions(
export function hasData(data?: PanelData): boolean {
return Boolean(data && data.series && data.series.length > 0 && data.series.some((frame) => frame.length > 0));
}
/**
* @internal
* Hidden legend config for previewing suggestion cards.
* This should only be used in previewModifier.
*/
export const SUGGESTIONS_LEGEND_OPTIONS: VizLegendOptions = {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
};

View File

@@ -1,362 +0,0 @@
import { getBackendSrv, config } from '@grafana/runtime';
import { ScopesApiClient } from './ScopesApiClient';
// Mock the runtime dependencies
jest.mock('@grafana/runtime', () => ({
getBackendSrv: jest.fn(),
config: {
featureToggles: {
useMultipleScopeNodesEndpoint: true,
useScopeSingleNodeEndpoint: true,
},
},
}));
jest.mock('@grafana/api-clients', () => ({
getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'),
}));
describe('ScopesApiClient', () => {
let apiClient: ScopesApiClient;
let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>;
beforeEach(() => {
mockBackendSrv = {
get: jest.fn(),
};
(getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv);
apiClient = new ScopesApiClient();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('fetchMultipleScopeNodes', () => {
it('should fetch multiple nodes by names', async () => {
const mockNodes = [
{
metadata: { name: 'node-1' },
spec: { nodeType: 'container', title: 'Node 1', parentName: '' },
},
{
metadata: { name: 'node-2' },
spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' },
},
];
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names: ['node-1', 'node-2'],
});
expect(result).toEqual(mockNodes);
});
it('should return empty array when names array is empty', async () => {
const result = await apiClient.fetchMultipleScopeNodes([]);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toEqual([]);
});
it('should return empty array when feature toggle is disabled', async () => {
config.featureToggles.useMultipleScopeNodesEndpoint = false;
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toEqual([]);
// Restore feature toggle
config.featureToggles.useMultipleScopeNodesEndpoint = true;
});
it('should handle API errors gracefully', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('Network error'));
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
expect(result).toEqual([]);
});
it('should handle response with no items field', async () => {
mockBackendSrv.get.mockResolvedValue({});
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
expect(result).toEqual([]);
});
it('should handle response with null items', async () => {
mockBackendSrv.get.mockResolvedValue({ items: null });
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
expect(result).toEqual([]);
});
it('should handle large arrays of node names', async () => {
const names = Array.from({ length: 100 }, (_, i) => `node-${i}`);
const mockNodes = names.map((name) => ({
metadata: { name },
spec: { nodeType: 'leaf', title: name, parentName: '' },
}));
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchMultipleScopeNodes(names);
expect(result).toEqual(mockNodes);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names,
});
});
it('should pass through node names exactly as provided', async () => {
const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes'];
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchMultipleScopeNodes(names);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names,
});
});
});
describe('fetchScopeNode', () => {
it('should fetch a single scope node by ID', async () => {
const mockNode = {
metadata: { name: 'test-node' },
spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' },
};
mockBackendSrv.get.mockResolvedValue(mockNode);
const result = await apiClient.fetchScopeNode('test-node');
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node');
expect(result).toEqual(mockNode);
});
it('should return undefined when feature toggle is disabled', async () => {
config.featureToggles.useScopeSingleNodeEndpoint = false;
const result = await apiClient.fetchScopeNode('test-node');
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toBeUndefined();
// Restore feature toggle
config.featureToggles.useScopeSingleNodeEndpoint = true;
});
it('should return undefined on API error', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
const result = await apiClient.fetchScopeNode('non-existent');
expect(result).toBeUndefined();
});
});
describe('fetchNodes', () => {
it('should fetch nodes with parent filter', async () => {
const mockNodes = [
{
metadata: { name: 'child-1' },
spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' },
},
];
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchNodes({ parent: 'parent' });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: 'parent',
query: undefined,
limit: 1000,
});
expect(result).toEqual(mockNodes);
});
it('should fetch nodes with query filter', async () => {
const mockNodes = [
{
metadata: { name: 'matching-node' },
spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' },
},
];
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchNodes({ query: 'matching' });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: 'matching',
limit: 1000,
});
expect(result).toEqual(mockNodes);
});
it('should respect custom limit', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchNodes({ limit: 50 });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: undefined,
limit: 50,
});
});
it('should throw error for invalid limit (too small)', async () => {
await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000');
});
it('should throw error for invalid limit (too large)', async () => {
await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000');
});
it('should use default limit of 1000 when not specified', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchNodes({});
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: undefined,
limit: 1000,
});
});
it('should return empty array on API error', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('API Error'));
const result = await apiClient.fetchNodes({ parent: 'test' });
expect(result).toEqual([]);
});
});
describe('fetchScope', () => {
it('should fetch a scope by name', async () => {
const mockScope = {
metadata: { name: 'test-scope' },
spec: {
title: 'Test Scope',
filters: [],
},
};
mockBackendSrv.get.mockResolvedValue(mockScope);
const result = await apiClient.fetchScope('test-scope');
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope');
expect(result).toEqual(mockScope);
});
it('should return undefined on error', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
const result = await apiClient.fetchScope('non-existent');
expect(result).toBeUndefined();
consoleErrorSpy.mockRestore();
});
it('should log error to console', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const error = new Error('Not found');
mockBackendSrv.get.mockRejectedValue(error);
await apiClient.fetchScope('non-existent');
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
consoleErrorSpy.mockRestore();
});
});
describe('fetchMultipleScopes', () => {
it('should fetch multiple scopes in parallel', async () => {
const mockScopes = [
{
metadata: { name: 'scope-1' },
spec: { title: 'Scope 1', filters: [] },
},
{
metadata: { name: 'scope-2' },
spec: { title: 'Scope 2', filters: [] },
},
];
mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]);
const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']);
expect(mockBackendSrv.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(mockScopes);
});
it('should filter out undefined scopes', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const mockScope = {
metadata: { name: 'scope-1' },
spec: { title: 'Scope 1', filters: [] },
};
mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found'));
const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']);
expect(result).toEqual([mockScope]);
consoleErrorSpy.mockRestore();
});
it('should return empty array when no scopes provided', async () => {
const result = await apiClient.fetchMultipleScopes([]);
expect(result).toEqual([]);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
});
});
describe('performance considerations', () => {
it('should make single batched request with fetchMultipleScopeNodes', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']);
// Should make exactly 1 API call
expect(mockBackendSrv.get).toHaveBeenCalledTimes(1);
});
it('should make N sequential requests with fetchScopeNode (old pattern)', async () => {
mockBackendSrv.get.mockResolvedValue({
metadata: { name: 'test' },
spec: { nodeType: 'leaf', title: 'Test', parentName: '' },
});
// Simulate old pattern of fetching nodes one by one
await Promise.all([
apiClient.fetchScopeNode('node-1'),
apiClient.fetchScopeNode('node-2'),
apiClient.fetchScopeNode('node-3'),
apiClient.fetchScopeNode('node-4'),
apiClient.fetchScopeNode('node-5'),
]);
// Should make 5 separate API calls
expect(mockBackendSrv.get).toHaveBeenCalledTimes(5);
});
});
});

View File

@@ -1,6 +1,5 @@
import { BehaviorSubject } from 'rxjs';
import { ScopeSpecFilter } from '@grafana/data';
import { LocationService } from '@grafana/runtime';
import { ScopesService } from './ScopesService';
@@ -17,20 +16,8 @@ describe('ScopesService', () => {
let locationService: jest.Mocked<LocationService>;
let selectorStateSubscription:
| ((
state: {
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
scopes?: Record<
string,
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
>;
},
prevState: {
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
scopes?: Record<
string,
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
>;
}
state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> },
prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }
) => void)
| undefined;
let dashboardsStateSubscription:
@@ -287,11 +274,9 @@ describe('ScopesService', () => {
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
scopes: {},
},
{
appliedScopes: [],
scopes: {},
}
);
@@ -313,11 +298,9 @@ describe('ScopesService', () => {
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }],
scopes: {},
},
{
appliedScopes: [],
scopes: {},
}
);
@@ -337,11 +320,9 @@ describe('ScopesService', () => {
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }],
scopes: {},
},
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
scopes: {},
}
);
@@ -363,11 +344,9 @@ describe('ScopesService', () => {
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1' }],
scopes: {},
},
{
appliedScopes: [],
scopes: {},
}
);
@@ -391,171 +370,15 @@ describe('ScopesService', () => {
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
scopes: {},
},
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
scopes: {},
}
);
expect(locationService.partial).not.toHaveBeenCalled();
});
describe('defaultPath support', () => {
it('should extract scope_node from defaultPath when available', () => {
if (!selectorStateSubscription) {
throw new Error('selectorStateSubscription not set');
}
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }],
scopes: {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
defaultPath: ['', 'parent-node', 'correct-node'],
filters: [],
},
},
},
},
{
appliedScopes: [],
scopes: {},
}
);
// Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes
expect(locationService.partial).toHaveBeenCalledWith(
{
scopes: ['scope1'],
scope_node: 'correct-node',
scope_parent: null,
},
true
);
});
it('should fallback to scopeNodeId when defaultPath is not available', () => {
if (!selectorStateSubscription) {
throw new Error('selectorStateSubscription not set');
}
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
scopes: {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
filters: [],
},
},
},
},
{
appliedScopes: [],
scopes: {},
}
);
// Should fallback to scopeNodeId from appliedScopes
expect(locationService.partial).toHaveBeenCalledWith(
{
scopes: ['scope1'],
scope_node: 'fallback-node',
scope_parent: null,
},
true
);
});
it('should handle empty defaultPath gracefully', () => {
if (!selectorStateSubscription) {
throw new Error('selectorStateSubscription not set');
}
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
scopes: {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
defaultPath: [],
filters: [],
},
},
},
},
{
appliedScopes: [],
scopes: {},
}
);
// Should fallback to scopeNodeId when defaultPath is empty
expect(locationService.partial).toHaveBeenCalledWith(
{
scopes: ['scope1'],
scope_node: 'fallback-node',
scope_parent: null,
},
true
);
});
it('should detect changes in defaultPath-derived scopeNodeId', () => {
if (!selectorStateSubscription) {
throw new Error('selectorStateSubscription not set');
}
selectorStateSubscription(
{
appliedScopes: [{ scopeId: 'scope1' }],
scopes: {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
defaultPath: ['', 'parent', 'new-node'],
filters: [],
},
},
},
},
{
appliedScopes: [{ scopeId: 'scope1' }],
scopes: {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
defaultPath: ['', 'parent', 'old-node'],
filters: [],
},
},
},
}
);
// Should detect the change in defaultPath-derived scopeNodeId
expect(locationService.partial).toHaveBeenCalledWith(
{
scopes: ['scope1'],
scope_node: 'new-node',
scope_parent: null,
},
true
);
});
});
it('should write navigation_scope to URL when navigationScope changes', () => {
if (!dashboardsStateSubscription) {
throw new Error('dashboardsStateSubscription not set');
@@ -799,30 +622,6 @@ describe('ScopesService', () => {
true
);
});
it('should use defaultPath for scope_node when enabling scopes', () => {
selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }];
selectorService.state.scopes = {
scope1: {
metadata: { name: 'scope1' },
spec: {
title: 'Scope 1',
defaultPath: ['', 'parent', 'correct-node-from-defaultPath'],
filters: [],
},
},
};
service.setEnabled(true);
// Should use defaultPath instead of scopeNodeId from appliedScopes
expect(locationService.partial).toHaveBeenCalledWith(
expect.objectContaining({
scope_node: 'correct-node-from-defaultPath',
}),
true
);
});
});
describe('back/forward navigation handling', () => {

View File

@@ -151,26 +151,12 @@ export class ScopesService implements ScopesContextValue {
// Update the URL based on change in the scopes state
this.subscriptions.push(
selectorService.subscribeToState((state, prevState) => {
const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId;
const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId;
const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId);
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
// Extract scopeNodeId from defaultPath when available
const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => {
const firstScope = appliedScopes[0];
if (!firstScope) {
return undefined;
}
const scope = scopes[firstScope.scopeId];
// Prefer defaultPath when available
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
}
return firstScope.scopeNodeId;
};
const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes);
const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes);
const scopesChanged = !isEqual(oldScopeNames, newScopeNames);
const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId;
@@ -244,7 +230,7 @@ export class ScopesService implements ScopesContextValue {
if (this.state.enabled !== enabled) {
this.updateState({ enabled });
if (enabled) {
const scopeNodeId = this.getScopeNodeIdForUrl();
const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId;
this.locationService.partial(
{
scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId),
@@ -257,29 +243,6 @@ export class ScopesService implements ScopesContextValue {
}
};
/**
* Extracts the scopeNodeId for URL syncing, preferring defaultPath when available.
* When a scope has defaultPath, that is the source of truth for the node ID.
* @private
*/
private getScopeNodeIdForUrl(): string | undefined {
const firstScope = this.selectorService.state.appliedScopes[0];
if (!firstScope) {
return undefined;
}
const scope = this.selectorService.state.scopes[firstScope.scopeId];
// Prefer scopeNodeId from defaultPath if available (most reliable source)
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
// Extract scopeNodeId from the last element of defaultPath
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
}
// Fallback to next in priority order: scopeNodeId from appliedScopes
return firstScope.scopeNodeId;
}
/**
* Returns observable that emits when relevant parts of the selectorService state change.
* @private

View File

@@ -31,33 +31,13 @@ export function ScopesInput({
onInputClick,
onRemoveAllClick,
}: ScopesInputProps) {
const firstScope = appliedScopes[0];
const scope = scopes[firstScope?.scopeId];
const scopeNodeId = appliedScopes[0]?.scopeNodeId;
const styles = useStyles2(getStyles);
// Prefer scopeNodeId from defaultPath if available (most reliable source)
let scopeNodeId: string | undefined;
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
// Extract scopeNodeId from the last element of defaultPath
scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
} else {
// Fallback to next in priority order: scopeNodeId from appliedScopes
scopeNodeId = firstScope?.scopeNodeId;
}
const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor
const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId);
// Prefer parentNodeId from defaultPath if available
let parentNodeId: string | undefined;
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) {
// Extract parentNodeId from the second-to-last element of defaultPath
parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2];
} else {
// Fallback to parent from scope node or recent scopes
const parentNodeIdFromRecentScopes = firstScope?.parentNodeId;
parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
}
// Get parent from scope node if available, otherwise fallback to parent
const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId);
// Prioritize scope node subtitle over parent node title
@@ -119,31 +99,16 @@ export function ScopesInput({
);
}
const getScopesPath = (
appliedScopes: SelectedScope[],
nodes: NodesMap,
defaultPath?: string[]
): string[] | undefined => {
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => {
let nicePath: string[] | undefined;
if (appliedScopes.length > 0) {
const firstScope = appliedScopes[0];
if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) {
let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes);
// Get reed of empty root section and the actual scope node
path = path.slice(1, -1);
// Prefer defaultPath from scope metadata
if (defaultPath && defaultPath.length > 1) {
// Get all nodes except the last one (which is the scope itself)
const pathNodeIds = defaultPath.slice(0, -1);
nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title);
}
// Fallback to walking the node tree
else if (firstScope.scopeNodeId) {
let path = getPathOfNode(firstScope.scopeNodeId, nodes);
// Get rid of empty root section and the actual scope node
path = path.slice(1, -1);
// We may not have all the nodes in path loaded
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
}
// We may not have all the nodes in path loaded
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
}
return nicePath;
@@ -162,9 +127,7 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable
return t('scopes.selector.input.tooltip', 'Select scope');
}
const firstScope = appliedScopes[0];
const scope = scopes[firstScope?.scopeId];
const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath);
const nicePath = getScopesPath(appliedScopes, nodes);
const scopeNames = appliedScopes.map((s) => {
if (s.scopeNodeId) {
return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId;

View File

@@ -19,7 +19,6 @@ import {
treeNodeAtPath,
} from './scopesTreeUtils';
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
export interface ScopesSelectorServiceState {
@@ -102,74 +101,22 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
};
private getNodePath = async (scopeNodeId: string, visited: Set<string> = new Set()): Promise<ScopeNode[]> => {
// Protect against circular references
if (visited.has(scopeNodeId)) {
console.error('Circular reference detected in node path', scopeNodeId);
return [];
}
private getNodePath = async (scopeNodeId: string): Promise<ScopeNode[]> => {
const node = await this.getScopeNode(scopeNodeId);
if (!node) {
return [];
}
// Add current node to visited set
const newVisited = new Set(visited);
newVisited.add(scopeNodeId);
const parentPath =
node.spec.parentName && node.spec.parentName !== ''
? await this.getNodePath(node.spec.parentName, newVisited)
: [];
node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : [];
return [...parentPath, node];
};
/**
* Determines the path to a scope node, preferring defaultPath from scope metadata.
* This is the single source of truth for path resolution.
*
* TODO: Consider making this public and exposing via a hook to avoid duplication
* with getScopesPath in ScopesInput.tsx
*
* @param scopeId - The scope ID to get the path for
* @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath
* @returns Promise resolving to array of ScopeNode objects representing the path
*/
private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise<ScopeNode[]> {
// 1. Check if scope has defaultPath (preferred method)
const scope = this.state.scopes[scopeId];
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
// Batch fetch all nodes in defaultPath
return await this.getScopeNodes(scope.spec.defaultPath);
}
// 2. Fall back to calculating path from scopeNodeId
if (scopeNodeId) {
return await this.getNodePath(scopeNodeId);
}
return [];
}
public resolvePathToRoot = async (
scopeNodeId: string,
tree: TreeNode,
scopeId?: string
tree: TreeNode
): Promise<{ path: ScopeNode[]; tree: TreeNode }> => {
let nodePath: ScopeNode[];
// Check if scope has defaultPath for optimized resolution
const scope = scopeId ? this.state.scopes[scopeId] : undefined;
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
// Use batch-fetched defaultPath (most efficient)
nodePath = await this.getPathForScope(scopeId!, scopeNodeId);
} else {
// Fall back to node-based path resolution
nodePath = await this.getNodePath(scopeNodeId);
}
const nodePath = await this.getNodePath(scopeNodeId);
const newTree = insertPathNodesIntoTree(tree, nodePath);
this.updateState({ tree: newTree });
@@ -260,39 +207,16 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => {
// Preserve existing children that have nested structure (from insertPathNodesIntoTree)
const existingChildren = treeNode.children || {};
const childrenToPreserve: Record<string, TreeNode> = {};
// Keep children that have a children property (object, not undefined)
// This includes both empty objects {} (from path insertion) and populated ones
for (const [key, child] of Object.entries(existingChildren)) {
// Preserve if children is an object (not undefined)
if (child.children !== undefined && typeof child.children === 'object') {
childrenToPreserve[key] = child;
}
}
// Start with preserved children, then add/update with fetched children
treeNode.children = { ...childrenToPreserve };
// Set parent query only when filtering within existing children
treeNode.children = {};
for (const node of childNodes) {
// If this child was preserved, merge with fetched data
if (childrenToPreserve[node.metadata.name]) {
treeNode.children[node.metadata.name] = {
...childrenToPreserve[node.metadata.name],
// Update query but keep nested children
query: query || '',
};
} else {
// New child from API
treeNode.children[node.metadata.name] = {
expanded: false,
scopeNodeId: node.metadata.name,
query: query || '',
children: undefined,
};
}
treeNode.children[node.metadata.name] = {
expanded: false,
scopeNodeId: node.metadata.name,
// Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting.
query: query || '',
children: undefined,
};
}
// Set loaded to true if node is a container
treeNode.childrenLoaded = true;
@@ -432,54 +356,16 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
const newScopesState = { ...this.state.scopes };
// Validate API response is an array
if (!Array.isArray(fetchedScopes)) {
console.error('Expected fetchedScopes to be an array, got:', typeof fetchedScopes);
this.updateState({ scopes: newScopesState, loading: false });
return;
}
for (const scope of fetchedScopes) {
newScopesState[scope.metadata.name] = scope;
}
// Pre-fetch the first scope's defaultPath to improve performance
// This makes the selector open instantly since all nodes are already cached
// We only need the first scope since that's what's used for expansion
const firstScope = fetchedScopes[0];
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 0) {
// Deduplicate and filter out already cached nodes
const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)];
const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]);
// If not provided, try to get the parent from the scope node
// When selected from recent scopes, we don't have access to the scope node (if it hasn't been loaded), but we do have access to the parent node from local storage.
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
const parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
if (nodesToFetch.length > 0) {
await this.getScopeNodes(nodesToFetch);
}
}
// Get scopeNode and parentNode, preferring defaultPath as the source of truth
let parentNode: ScopeNode | undefined;
let scopeNodeId: string | undefined;
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) {
// Extract from defaultPath (most reliable source)
// defaultPath format: ['', 'parent-id', 'scope-node-id', ...]
scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1];
const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2];
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined;
} else {
// Fallback to next in priority order
scopeNodeId = scopes[0]?.scopeNodeId;
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
}
this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId);
this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId);
this.updateState({ scopes: newScopesState, loading: false });
}
};
@@ -489,7 +375,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Check if we are currently on an active scope navigation
const currentPath = locationService.getLocation().pathname;
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
if (!('url' in s.spec)) {
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
return false;
}
return isCurrentPath(currentPath, s.spec.url);
@@ -500,6 +386,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
!activeScopeNavigation &&
scopeNode &&
scopeNode.spec.redirectPath &&
typeof scopeNode.spec.redirectPath === 'string' &&
// Don't redirect if we're already on the target path
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
) {
@@ -515,6 +402,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
if (
firstScopeNavigation &&
'url' in firstScopeNavigation.spec &&
typeof firstScopeNavigation.spec.url === 'string' &&
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
firstScopeNavigation.spec.url.includes('/d/') &&
// Don't redirect if we're already on the target path
@@ -574,11 +462,13 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
const recentScopes = parseScopesFromLocalStorage(content);
// Load parent nodes for recent scopes
return Object.fromEntries(
const parentNodes = Object.fromEntries(
recentScopes
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
.filter(([key, parentNode]) => parentNode !== undefined && key !== undefined)
);
return parentNodes;
};
/**
@@ -609,42 +499,40 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
let newTree = closeNodes(this.state.tree);
if (this.state.selectedScopes.length && this.state.selectedScopes[0].scopeNodeId) {
try {
// Get the path for the selected scope, preferring defaultPath from scope metadata
const pathNodes = await this.getPathForScope(
this.state.selectedScopes[0].scopeId,
this.state.selectedScopes[0].scopeNodeId
);
let path = getPathOfNode(this.state.selectedScopes[0].scopeNodeId, this.state.nodes);
if (pathNodes.length > 0) {
// Convert to string path
const stringPath = pathNodes.map((n) => n.metadata.name);
stringPath.unshift(''); // Add root segment
// Get node at path, and request it's children if they don't exist yet
let nodeAtPath = treeNodeAtPath(newTree, path);
// Check if nodes are in tree
let nodeAtPath = treeNodeAtPath(newTree, stringPath);
// If nodes aren't in tree yet, insert them
if (!nodeAtPath) {
newTree = insertPathNodesIntoTree(newTree, pathNodes);
// Update state so loadNodeChildren can see the inserted nodes
this.updateState({ tree: newTree });
}
// Load children of the parent node if needed to show all siblings
const parentPath = stringPath.slice(0, -1);
const parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
newTree = newTreeWithChildren;
}
// Expand the nodes to show the selected scope
newTree = expandNodes(newTree, parentPath);
// In the cases where nodes are not in the tree yet
if (!nodeAtPath) {
try {
const result = await this.resolvePathToRoot(this.state.selectedScopes[0].scopeNodeId, newTree);
newTree = result.tree;
// Update path to use the resolved path since nodes have been fetched
path = result.path.map((n) => n.metadata.name);
path.unshift('');
nodeAtPath = treeNodeAtPath(newTree, path);
} catch (error) {
console.error('Failed to resolve path to root', error);
}
}
// We have resolved to root, which means the parent node should be available
let parentPath = path.slice(0, -1);
let parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
// This will update the tree with the children
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
newTree = newTreeWithChildren;
}
// Expand the nodes to the selected scope - must be done after loading children
try {
newTree = expandNodes(newTree, parentPath);
} catch (error) {
console.error('Failed to expand to selected scope', error);
console.error('Failed to expand nodes', error);
}
}
@@ -692,14 +580,9 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
// Get nodes that are not in the cache
const nodesToFetch = scopeNodeNames.filter((name) => !nodesMap[name]);
if (nodesToFetch.length > 0) {
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
// Handle case where API returns undefined or non-array
if (Array.isArray(nodes)) {
for (const node of nodes) {
nodesMap[node.metadata.name] = node;
}
}
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
for (const node of nodes) {
nodesMap[node.metadata.name] = node;
}
const newNodes = { ...this.state.nodes, ...nodesMap };

View File

@@ -127,27 +127,17 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => {
if (!childNodeName) {
console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]);
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
return;
}
// Create node if it doesn't exist
if (!treeNode.children[childNodeName]) {
treeNode.children[childNodeName] = {
expanded: false,
scopeNodeId: childNodeName,
query: '',
children: {},
childrenLoaded: false,
};
} else {
// Node exists, ensure it has children object for nested insertion
if (treeNode.children[childNodeName].children === undefined) {
treeNode.children[childNodeName] = {
...treeNode.children[childNodeName],
children: {},
};
}
return treeNode;
}
treeNode.children[childNodeName] = {
expanded: false,
scopeNodeId: childNodeName,
query: '',
children: undefined,
childrenLoaded: false,
};
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
return treeNode;
});
}
return newTree;

View File

@@ -105,6 +105,7 @@ describe('Selector', () => {
// Lowercase because we don't have any backend that returns the correct case, then it falls back to the value in the URL
expectScopesSelectorValue('grafana');
await openSelector();
//screen.debug(undefined, 100000);
expectResultApplicationsGrafanaSelected();
jest.spyOn(locationService, 'getLocation').mockRestore();
@@ -132,12 +133,11 @@ describe('Selector', () => {
expectRecentScope('Grafana Applications');
expectRecentScope('Grafana, Mimir Applications');
await selectRecentScope('Grafana Applications');
await jest.runOnlyPendingTimersAsync();
expectScopesSelectorValue('Grafana');
await openSelector();
// Collapse tree to root level to see recent scopes section
// Close to root node so we can see the recent scopes
await expandResultApplications();
await expandRecentScopes();
@@ -156,8 +156,8 @@ describe('Selector', () => {
await applyScopes();
await openSelector();
// Tree expands to show selected scope, so recent scopes are not visible
// (recent scopes only show at root level with tree collapsed)
// Close to root node so we can try to see the recent scopes
await expandResultApplications();
expectRecentScopeNotPresentInDocument();
});
@@ -175,7 +175,6 @@ describe('Selector', () => {
await applyScopes();
// Deselect all scopes
await hoverSelector();
await clearSelector();
// Recent scopes should still be available
@@ -198,7 +197,6 @@ describe('Selector', () => {
await selectResultApplicationsMimir();
await applyScopes();
await hoverSelector();
await clearSelector();
// Check recent scopes are updated

View File

@@ -47,7 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => {
export const updateScopes = async (service: ScopesService, scopes: string[]) =>
act(async () => service.changeScopes(scopes));
export const openSelector = async () => click(getSelectorInput);
export const hoverSelector = async () => userEvent.hover(getSelectorInput());
export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput());
export const clearSelector = async () => click(getSelectorClear);
export const applyScopes = async () => {
await click(getSelectorApply);

View File

@@ -7,7 +7,8 @@ import {
VisualizationSuggestionsSupplier,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { defaultNumericVizOptions, SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
import { LegendDisplayMode } from '@grafana/schema';
import { defaultNumericVizOptions } from 'app/features/panel/suggestions/utils';
import { PieChartLabels, Options, PieChartType } from './panelcfg.gen';
@@ -15,13 +16,12 @@ const withDefaults = (suggestion: VisualizationSuggestion<Options>): Visualizati
defaultsDeep(suggestion, {
options: {
displayLabels: [PieChartLabels.Percent],
},
cardOptions: {
previewModifier: (s) => {
s.options!.legend = {
...SUGGESTIONS_LEGEND_OPTIONS,
values: [],
};
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
values: [],
showLegend: false,
},
},
} satisfies VisualizationSuggestion<Options>);

View File

@@ -10,9 +10,15 @@ import {
VisualizationSuggestionsSupplier,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { GraphDrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, StackingMode } from '@grafana/schema';
import {
GraphDrawStyle,
GraphFieldConfig,
GraphGradientMode,
LegendDisplayMode,
LineInterpolation,
StackingMode,
} from '@grafana/schema';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
import { Options } from './panelcfg.gen';
@@ -23,6 +29,14 @@ const withDefaults = (
suggestion: VisualizationSuggestion<Options, GraphFieldConfig>
): VisualizationSuggestion<Options, GraphFieldConfig> =>
defaultsDeep(suggestion, {
options: {
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
},
},
fieldConfig: {
defaults: {
custom: {},
@@ -32,7 +46,6 @@ const withDefaults = (
cardOptions: {
previewModifier: (s) => {
s.options!.disableKeyboardEvents = true;
s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS;
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}

View File

@@ -1,9 +1,8 @@
import { Field, FieldType, PanelPlugin, VisualizationSuggestionScore } from '@grafana/data';
import { t } from '@grafana/i18n';
import { GraphDrawStyle } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { commonOptionsBuilder, LegendDisplayMode } from '@grafana/ui';
import { optsWithHideZeros } from '@grafana/ui/internal';
import { SUGGESTIONS_LEGEND_OPTIONS } from 'app/features/panel/suggestions/utils';
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
@@ -50,6 +49,14 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
return [
{
score: VisualizationSuggestionScore.Good,
options: {
legend: {
calcs: [],
displayMode: LegendDisplayMode.Hidden,
placement: 'right',
showLegend: false,
},
},
fieldConfig: {
defaults: {
custom: {},
@@ -58,7 +65,6 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TrendPanel)
},
cardOptions: {
previewModifier: (s) => {
s.options!.legend = SUGGESTIONS_LEGEND_OPTIONS;
if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) {
s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2);
}

View File

@@ -5,7 +5,6 @@ import { Role } from './accessControl';
export interface OrgUser extends WithAccessControlMetadata {
avatarUrl: string;
email: string;
created?: string;
lastSeenAt: string;
lastSeenAtAge: string;
login: string;
@@ -50,7 +49,6 @@ export interface UserDTO extends WithAccessControlMetadata {
theme?: string;
avatarUrl?: string;
orgId?: number;
created?: string;
lastSeenAt?: string;
lastSeenAtAge?: string;
licensedRole?: string;

8
public/openapi3.json generated
View File

@@ -8018,10 +8018,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"format": "date-time",
"type": "string"
},
"email": {
"type": "string"
},
@@ -12942,10 +12938,6 @@
"avatarUrl": {
"type": "string"
},
"created": {
"format": "date-time",
"type": "string"
},
"email": {
"type": "string"
},