Compare commits
1 Commits
update-ale
...
fastfrwrd/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec42bbaba9 |
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -75,3 +75,5 @@ return (
|
||||
</Toggletip>
|
||||
);
|
||||
```
|
||||
|
||||
<ArgTypes of={Toggletip} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
table: css({
|
||||
width: '100%',
|
||||
'th:first-child': {
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -134,7 +134,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
label: 'LegendLabelCell',
|
||||
maxWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: theme.spacing(16),
|
||||
}),
|
||||
labelCellInner: css({
|
||||
label: 'LegendLabelCellInner',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
30
pkg/services/ngalert/notifier/receiver_svc_err.go
Normal file
30
pkg/services/ngalert/notifier/receiver_svc_err.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
8
public/api-enterprise-spec.json
generated
8
public/api-enterprise-spec.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
8
public/api-merged.json
generated
8
public/api-merged.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -5,5 +5,4 @@ export interface VizTypeChangeDetails {
|
||||
options?: Record<string, unknown>;
|
||||
fieldConfig?: FieldConfigSource;
|
||||
withModKey?: boolean;
|
||||
fromSuggestions?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
8
public/openapi3.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user