mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge branch 'main' into gabor/logs-volume-disable
This commit is contained in:
commit
ff9f2d1f51
@ -77,9 +77,6 @@ exports[`no enzyme tests`] = {
|
|||||||
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:4057721851": [
|
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:4057721851": [
|
||||||
[1, 19, 13, "RegExp match", "2409514259"]
|
[1, 19, 13, "RegExp match", "2409514259"]
|
||||||
],
|
],
|
||||||
"public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx:4128034878": [
|
|
||||||
[0, 26, 13, "RegExp match", "2409514259"]
|
|
||||||
],
|
|
||||||
"public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx:57753101": [
|
"public/app/plugins/datasource/influxdb/components/ConfigEditor.test.tsx:57753101": [
|
||||||
[0, 19, 13, "RegExp match", "2409514259"]
|
[0, 19, 13, "RegExp match", "2409514259"]
|
||||||
],
|
],
|
||||||
@ -1615,11 +1612,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "12"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"]
|
|
||||||
],
|
],
|
||||||
"packages/grafana-ui/src/components/Select/SelectMenu.tsx:5381": [
|
"packages/grafana-ui/src/components/Select/SelectMenu.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
|
@ -101,7 +101,7 @@
|
|||||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||||
"@lingui/cli": "3.14.0",
|
"@lingui/cli": "3.14.0",
|
||||||
"@lingui/macro": "3.14.0",
|
"@lingui/macro": "3.14.0",
|
||||||
"@microsoft/api-extractor": "7.28.6",
|
"@microsoft/api-extractor": "7.29.5",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.7",
|
"@pmmmwh/react-refresh-webpack-plugin": "0.5.7",
|
||||||
"@react-types/button": "3.5.1",
|
"@react-types/button": "3.5.1",
|
||||||
"@react-types/menu": "3.6.1",
|
"@react-types/menu": "3.6.1",
|
||||||
@ -114,7 +114,7 @@
|
|||||||
"@testing-library/jest-dom": "5.16.4",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/react-hooks": "8.0.1",
|
"@testing-library/react-hooks": "8.0.1",
|
||||||
"@testing-library/user-event": "14.3.0",
|
"@testing-library/user-event": "14.4.3",
|
||||||
"@types/angular": "1.8.4",
|
"@types/angular": "1.8.4",
|
||||||
"@types/angular-route": "1.7.2",
|
"@types/angular-route": "1.7.2",
|
||||||
"@types/classnames": "2.3.0",
|
"@types/classnames": "2.3.0",
|
||||||
@ -189,7 +189,7 @@
|
|||||||
"eslint-plugin-jest": "26.6.0",
|
"eslint-plugin-jest": "26.6.0",
|
||||||
"eslint-plugin-jsdoc": "39.3.3",
|
"eslint-plugin-jsdoc": "39.3.3",
|
||||||
"eslint-plugin-lodash": "7.4.0",
|
"eslint-plugin-lodash": "7.4.0",
|
||||||
"eslint-plugin-react": "7.29.4",
|
"eslint-plugin-react": "7.31.0",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"eslint-webpack-plugin": "3.2.0",
|
"eslint-webpack-plugin": "3.2.0",
|
||||||
"expose-loader": "4.0.0",
|
"expose-loader": "4.0.0",
|
||||||
@ -404,7 +404,7 @@
|
|||||||
"resolutions": {
|
"resolutions": {
|
||||||
"underscore": "1.13.4",
|
"underscore": "1.13.4",
|
||||||
"@types/slate": "0.47.2",
|
"@types/slate": "0.47.2",
|
||||||
"@microsoft/api-extractor-model": "7.22.1",
|
"@microsoft/api-extractor-model": "7.23.3",
|
||||||
"@rushstack/node-core-library": "3.49.0",
|
"@rushstack/node-core-library": "3.49.0",
|
||||||
"@rushstack/rig-package": "0.3.13",
|
"@rushstack/rig-package": "0.3.13",
|
||||||
"@rushstack/ts-command-line": "4.12.1",
|
"@rushstack/ts-command-line": "4.12.1",
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
"@testing-library/jest-dom": "5.16.4",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/react-hooks": "8.0.1",
|
"@testing-library/react-hooks": "8.0.1",
|
||||||
"@testing-library/user-event": "14.3.0",
|
"@testing-library/user-event": "14.4.3",
|
||||||
"@types/history": "4.7.11",
|
"@types/history": "4.7.11",
|
||||||
"@types/jest": "28.1.6",
|
"@types/jest": "28.1.6",
|
||||||
"@types/jquery": "3.5.14",
|
"@types/jquery": "3.5.14",
|
||||||
|
@ -574,6 +574,7 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
|
|||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
meta: DataSourcePluginMeta;
|
meta: DataSourcePluginMeta;
|
||||||
|
readOnly: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
jsonData: T;
|
jsonData: T;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
"@rollup/plugin-node-resolve": "13.3.0",
|
"@rollup/plugin-node-resolve": "13.3.0",
|
||||||
"@testing-library/dom": "8.13.0",
|
"@testing-library/dom": "8.13.0",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/user-event": "14.3.0",
|
"@testing-library/user-event": "14.4.3",
|
||||||
"@types/angular": "1.8.4",
|
"@types/angular": "1.8.4",
|
||||||
"@types/history": "4.7.11",
|
"@types/history": "4.7.11",
|
||||||
"@types/jest": "28.1.6",
|
"@types/jest": "28.1.6",
|
||||||
|
@ -46,6 +46,7 @@ export interface DataSourcePickerProps {
|
|||||||
inputId?: string;
|
inputId?: string;
|
||||||
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
|
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -186,7 +187,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
noOptionsMessage="No datasources found"
|
noOptionsMessage="No datasources found"
|
||||||
value={value ?? null}
|
value={value ?? null}
|
||||||
invalid={!!error}
|
invalid={Boolean(error) || Boolean(this.props.invalid)}
|
||||||
getOptionLabel={(o) => {
|
getOptionLabel={(o) => {
|
||||||
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
|
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
|
||||||
return (
|
return (
|
||||||
|
@ -130,7 +130,7 @@
|
|||||||
"@testing-library/jest-dom": "5.16.4",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/react-hooks": "8.0.1",
|
"@testing-library/react-hooks": "8.0.1",
|
||||||
"@testing-library/user-event": "14.3.0",
|
"@testing-library/user-event": "14.4.3",
|
||||||
"@types/classnames": "2.3.0",
|
"@types/classnames": "2.3.0",
|
||||||
"@types/common-tags": "^1.8.0",
|
"@types/common-tags": "^1.8.0",
|
||||||
"@types/d3": "7.4.0",
|
"@types/d3": "7.4.0",
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { act, render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
||||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Cascader, CascaderOption, CascaderProps } from './Cascader';
|
import { Cascader, CascaderOption, CascaderProps } from './Cascader';
|
||||||
@ -47,7 +46,7 @@ describe('Cascader', () => {
|
|||||||
const placeholder = 'cascader-placeholder';
|
const placeholder = 'cascader-placeholder';
|
||||||
|
|
||||||
describe('options from state change', () => {
|
describe('options from state change', () => {
|
||||||
let user: UserEvent;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
@ -13,9 +13,11 @@ export interface Props {
|
|||||||
/** Disable button click action */
|
/** Disable button click action */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
|
/** Close after delete button is clicked */
|
||||||
|
closeOnConfirm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label': ariaLabel }) => {
|
export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label': ariaLabel, closeOnConfirm }) => {
|
||||||
return (
|
return (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
@ -23,6 +25,7 @@ export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label
|
|||||||
size={size || 'md'}
|
size={size || 'md'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
|
closeOnConfirm={closeOnConfirm}
|
||||||
>
|
>
|
||||||
<Button aria-label={ariaLabel} variant="destructive" icon="times" size={size || 'sm'} />
|
<Button aria-label={ariaLabel} variant="destructive" icon="times" size={size || 'sm'} />
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
import { components, ContainerProps, GroupBase } from 'react-select';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
|
|
||||||
import { stylesFactory } from '../../themes';
|
|
||||||
import { useTheme2 } from '../../themes/ThemeContext';
|
|
||||||
import { focusCss } from '../../themes/mixins';
|
|
||||||
import { sharedInputStyle } from '../Forms/commonStyles';
|
|
||||||
import { getInputStyles } from '../Input/Input';
|
|
||||||
|
|
||||||
export const SelectContainer = <Option, isMulti extends boolean, Group extends GroupBase<Option>>(
|
|
||||||
props: ContainerProps<Option, isMulti, Group> & { isFocused: boolean }
|
|
||||||
) => {
|
|
||||||
const { isDisabled, isFocused, children } = props;
|
|
||||||
|
|
||||||
const theme = useTheme2();
|
|
||||||
const styles = getSelectContainerStyles(theme, isFocused, isDisabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<components.SelectContainer {...props} className={cx(styles.wrapper, props.className)}>
|
|
||||||
{children}
|
|
||||||
</components.SelectContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectContainerStyles = stylesFactory((theme: GrafanaTheme2, focused: boolean, disabled: boolean) => {
|
|
||||||
const styles = getInputStyles({ theme, invalid: false });
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapper: cx(
|
|
||||||
styles.wrapper,
|
|
||||||
sharedInputStyle(theme, false),
|
|
||||||
focused &&
|
|
||||||
css`
|
|
||||||
${focusCss(theme.v1)}
|
|
||||||
`,
|
|
||||||
disabled && styles.inputDisabled,
|
|
||||||
css`
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
min-height: 32px;
|
|
||||||
height: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
/* Input padding is applied to the InputControl so the menu is aligned correctly */
|
|
||||||
padding: 0;
|
|
||||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
|
||||||
`
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
@ -18,7 +18,6 @@ export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
|
|||||||
export interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
export interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
||||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||||
value?: SelectableValue<T> | null;
|
value?: SelectableValue<T> | null;
|
||||||
invalid?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
|
export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
|
||||||
|
@ -317,28 +317,28 @@ export function SelectBase<T>({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
LoadingIndicator(props: any) {
|
LoadingIndicator() {
|
||||||
return <Spinner inline={true} />;
|
return <Spinner inline />;
|
||||||
},
|
},
|
||||||
LoadingMessage(props: any) {
|
LoadingMessage() {
|
||||||
return <div className={styles.loadingMessage}>{loadingMessage}</div>;
|
return <div className={styles.loadingMessage}>{loadingMessage}</div>;
|
||||||
},
|
},
|
||||||
NoOptionsMessage(props: any) {
|
NoOptionsMessage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.loadingMessage} aria-label="No options provided">
|
<div className={styles.loadingMessage} aria-label="No options provided">
|
||||||
{noOptionsMessage}
|
{noOptionsMessage}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
DropdownIndicator(props: any) {
|
DropdownIndicator(props) {
|
||||||
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />;
|
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />;
|
||||||
},
|
},
|
||||||
SingleValue(props: any) {
|
SingleValue(props: any) {
|
||||||
return <SingleValue {...props} disabled={disabled} />;
|
return <SingleValue {...props} disabled={disabled} />;
|
||||||
},
|
},
|
||||||
|
SelectContainer,
|
||||||
MultiValueContainer: MultiValueContainer,
|
MultiValueContainer: MultiValueContainer,
|
||||||
MultiValueRemove: MultiValueRemove,
|
MultiValueRemove: MultiValueRemove,
|
||||||
SelectContainer,
|
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
styles={selectStyles}
|
styles={selectStyles}
|
||||||
|
@ -10,19 +10,24 @@ import { focusCss } from '../../themes/mixins';
|
|||||||
import { sharedInputStyle } from '../Forms/commonStyles';
|
import { sharedInputStyle } from '../Forms/commonStyles';
|
||||||
import { getInputStyles } from '../Input/Input';
|
import { getInputStyles } from '../Input/Input';
|
||||||
|
|
||||||
// isFocus prop is actually available, but its not in the types for the version we have.
|
import { CustomComponentProps } from './types';
|
||||||
export interface SelectContainerProps<Option, isMulti extends boolean, Group extends GroupBase<Option>>
|
|
||||||
extends BaseContainerProps<Option, isMulti, Group> {
|
// prettier-ignore
|
||||||
isFocused: boolean;
|
export type SelectContainerProps<Option, isMulti extends boolean, Group extends GroupBase<Option>> =
|
||||||
}
|
BaseContainerProps<Option, isMulti, Group> & CustomComponentProps<Option, isMulti, Group>;
|
||||||
|
|
||||||
export const SelectContainer = <Option, isMulti extends boolean, Group extends GroupBase<Option>>(
|
export const SelectContainer = <Option, isMulti extends boolean, Group extends GroupBase<Option>>(
|
||||||
props: SelectContainerProps<Option, isMulti, Group>
|
props: SelectContainerProps<Option, isMulti, Group>
|
||||||
) => {
|
) => {
|
||||||
const { isDisabled, isFocused, children } = props;
|
const {
|
||||||
|
isDisabled,
|
||||||
|
isFocused,
|
||||||
|
children,
|
||||||
|
selectProps: { invalid = false },
|
||||||
|
} = props;
|
||||||
|
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getSelectContainerStyles(theme, isFocused, isDisabled);
|
const styles = getSelectContainerStyles(theme, isFocused, isDisabled, invalid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<components.SelectContainer {...props} className={cx(styles.wrapper, props.className)}>
|
<components.SelectContainer {...props} className={cx(styles.wrapper, props.className)}>
|
||||||
@ -31,35 +36,37 @@ export const SelectContainer = <Option, isMulti extends boolean, Group extends G
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectContainerStyles = stylesFactory((theme: GrafanaTheme2, focused: boolean, disabled: boolean) => {
|
const getSelectContainerStyles = stylesFactory(
|
||||||
const styles = getInputStyles({ theme, invalid: false });
|
(theme: GrafanaTheme2, focused: boolean, disabled: boolean, invalid: boolean) => {
|
||||||
|
const styles = getInputStyles({ theme, invalid });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wrapper: cx(
|
wrapper: cx(
|
||||||
styles.wrapper,
|
styles.wrapper,
|
||||||
sharedInputStyle(theme, false),
|
sharedInputStyle(theme, invalid),
|
||||||
focused &&
|
focused &&
|
||||||
|
css`
|
||||||
|
${focusCss(theme.v1)}
|
||||||
|
`,
|
||||||
|
disabled && styles.inputDisabled,
|
||||||
css`
|
css`
|
||||||
${focusCss(theme.v1)}
|
position: relative;
|
||||||
`,
|
box-sizing: border-box;
|
||||||
disabled && styles.inputDisabled,
|
/* The display property is set by the styles prop in SelectBase because it's dependant on the width prop */
|
||||||
css`
|
flex-direction: row;
|
||||||
position: relative;
|
flex-wrap: wrap;
|
||||||
box-sizing: border-box;
|
align-items: stretch;
|
||||||
/* The display property is set by the styles prop in SelectBase because it's dependant on the width prop */
|
justify-content: space-between;
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
/* Input padding is applied to the InputControl so the menu is aligned correctly */
|
/* Input padding is applied to the InputControl so the menu is aligned correctly */
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ActionMeta as SelectActionMeta, GroupBase, OptionsOrGroups } from 'react-select';
|
import {
|
||||||
|
ActionMeta as SelectActionMeta,
|
||||||
|
CommonProps as ReactSelectCommonProps,
|
||||||
|
GroupBase,
|
||||||
|
OptionsOrGroups,
|
||||||
|
} from 'react-select';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
@ -103,10 +108,13 @@ export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'o
|
|||||||
onChange: (item: Array<SelectableValue<T>>) => {} | void;
|
onChange: (item: Array<SelectableValue<T>>) => {} | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is the type of *our* SelectBase component, not ReactSelect's prop, although
|
||||||
|
// they should be mostly compatible.
|
||||||
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
|
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is used for the `renderControl` prop on *our* SelectBase component
|
||||||
export interface CustomControlProps<T> {
|
export interface CustomControlProps<T> {
|
||||||
ref: React.Ref<any>;
|
ref: React.Ref<any>;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -133,3 +141,20 @@ export type SelectOptions<T = any> =
|
|||||||
| Array<SelectableValue<T> | SelectableOptGroup<T> | Array<SelectableOptGroup<T>>>;
|
| Array<SelectableValue<T> | SelectableOptGroup<T> | Array<SelectableOptGroup<T>>>;
|
||||||
|
|
||||||
export type FormatOptionLabelMeta<T> = { context: string; inputValue: string; selectValue: Array<SelectableValue<T>> };
|
export type FormatOptionLabelMeta<T> = { context: string; inputValue: string; selectValue: Array<SelectableValue<T>> };
|
||||||
|
|
||||||
|
// This is the type of `selectProps` our custom components (like SelectContainer, etc) recieve
|
||||||
|
// It's slightly different to the base react select props because we pass in additional props directly to
|
||||||
|
// react select
|
||||||
|
export type ReactSelectProps<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = ReactSelectCommonProps<
|
||||||
|
Option,
|
||||||
|
IsMulti,
|
||||||
|
Group
|
||||||
|
>['selectProps'] & {
|
||||||
|
invalid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use this type when implementing custom components for react select.
|
||||||
|
// See SelectContainerProps in SelectContainer.tsx
|
||||||
|
export interface CustomComponentProps<Option, isMulti extends boolean, Group extends GroupBase<Option>> {
|
||||||
|
selectProps: ReactSelectProps<Option, isMulti, Group>;
|
||||||
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Slider } from './Slider';
|
import { Slider } from './Slider';
|
||||||
@ -12,7 +11,7 @@ const sliderProps: SliderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('Slider', () => {
|
describe('Slider', () => {
|
||||||
let user: UserEvent;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup();
|
user = userEvent.setup();
|
||||||
|
@ -93,6 +93,7 @@ export const getAvailableIcons = () =>
|
|||||||
'gf-bar-alignment-after',
|
'gf-bar-alignment-after',
|
||||||
'gf-bar-alignment-before',
|
'gf-bar-alignment-before',
|
||||||
'gf-bar-alignment-center',
|
'gf-bar-alignment-center',
|
||||||
|
'gf-glue',
|
||||||
'gf-grid',
|
'gf-grid',
|
||||||
'gf-interpolation-linear',
|
'gf-interpolation-linear',
|
||||||
'gf-interpolation-smooth',
|
'gf-interpolation-smooth',
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||||
"@testing-library/jest-dom": "5.16.4",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.4",
|
"@testing-library/react": "12.1.4",
|
||||||
"@testing-library/user-event": "14.3.0",
|
"@testing-library/user-event": "14.4.3",
|
||||||
"@types/classnames": "^2.2.7",
|
"@types/classnames": "^2.2.7",
|
||||||
"@types/deep-freeze": "^0.1.1",
|
"@types/deep-freeze": "^0.1.1",
|
||||||
"@types/grafana__slate-react": "npm:@types/slate-react@0.22.5",
|
"@types/grafana__slate-react": "npm:@types/slate-react@0.22.5",
|
||||||
|
@ -50,7 +50,7 @@ export class UnthemedCanvasSpanGraph extends React.PureComponent<CanvasSpanGraph
|
|||||||
this._canvasElm = undefined;
|
this._canvasElm = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
getColor = (key: string) => getRgbColorByKey(key, this.props.theme);
|
getColor = (key: string) => getRgbColorByKey(key);
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._draw();
|
this._draw();
|
||||||
|
@ -392,7 +392,6 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
hoverIndentGuideIds,
|
hoverIndentGuideIds,
|
||||||
addHoverIndentGuideId,
|
addHoverIndentGuideId,
|
||||||
removeHoverIndentGuideId,
|
removeHoverIndentGuideId,
|
||||||
theme,
|
|
||||||
createSpanLink,
|
createSpanLink,
|
||||||
focusedSpanId,
|
focusedSpanId,
|
||||||
focusedSpanIdForSearch,
|
focusedSpanIdForSearch,
|
||||||
@ -401,7 +400,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
if (!trace) {
|
if (!trace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const color = getColorByKey(serviceName, theme);
|
const color = getColorByKey(serviceName);
|
||||||
const isCollapsed = childrenHiddenIDs.has(spanID);
|
const isCollapsed = childrenHiddenIDs.has(spanID);
|
||||||
const isDetailExpanded = detailStates.has(spanID);
|
const isDetailExpanded = detailStates.has(spanID);
|
||||||
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
|
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
|
||||||
@ -415,7 +414,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
if (rpcSpan) {
|
if (rpcSpan) {
|
||||||
const rpcViewBounds = this.getViewedBounds()(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
|
const rpcViewBounds = this.getViewedBounds()(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
|
||||||
rpc = {
|
rpc = {
|
||||||
color: getColorByKey(rpcSpan.process.serviceName, theme),
|
color: getColorByKey(rpcSpan.process.serviceName),
|
||||||
operationName: rpcSpan.operationName,
|
operationName: rpcSpan.operationName,
|
||||||
serviceName: rpcSpan.process.serviceName,
|
serviceName: rpcSpan.process.serviceName,
|
||||||
viewEnd: rpcViewBounds.end,
|
viewEnd: rpcViewBounds.end,
|
||||||
@ -431,7 +430,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
if (!span.hasChildren && peerServiceKV && isKindClient(span)) {
|
if (!span.hasChildren && peerServiceKV && isKindClient(span)) {
|
||||||
noInstrumentedServer = {
|
noInstrumentedServer = {
|
||||||
serviceName: peerServiceKV.value,
|
serviceName: peerServiceKV.value,
|
||||||
color: getColorByKey(peerServiceKV.value, theme),
|
color: getColorByKey(peerServiceKV.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -487,7 +486,6 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
addHoverIndentGuideId,
|
addHoverIndentGuideId,
|
||||||
removeHoverIndentGuideId,
|
removeHoverIndentGuideId,
|
||||||
linksGetter,
|
linksGetter,
|
||||||
theme,
|
|
||||||
createSpanLink,
|
createSpanLink,
|
||||||
focusedSpanId,
|
focusedSpanId,
|
||||||
createFocusSpanLink,
|
createFocusSpanLink,
|
||||||
@ -497,7 +495,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
|||||||
if (!trace || !detailState) {
|
if (!trace || !detailState) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const color = getColorByKey(serviceName, theme);
|
const color = getColorByKey(serviceName);
|
||||||
const styles = getStyles(this.props);
|
const styles = getStyles(this.props);
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
|
<div className={styles.row} key={key} style={{ ...style, zIndex: 1 }} {...attrs}>
|
||||||
|
@ -12,21 +12,19 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { createTheme } from '@grafana/data';
|
|
||||||
|
|
||||||
import { getColorByKey, clear } from './color-generator';
|
import { getColorByKey, clear } from './color-generator';
|
||||||
|
|
||||||
it('gives the same color for the same key', () => {
|
it('gives the same color for the same key', () => {
|
||||||
clear();
|
clear();
|
||||||
const colorOne = getColorByKey('serviceA', createTheme());
|
const colorOne = getColorByKey('serviceA');
|
||||||
const colorTwo = getColorByKey('serviceA', createTheme());
|
const colorTwo = getColorByKey('serviceA');
|
||||||
expect(colorOne).toBe(colorTwo);
|
expect(colorOne).toBe(colorTwo);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives different colors for each for each key', () => {
|
it('gives different colors for each for each key', () => {
|
||||||
clear();
|
clear();
|
||||||
const colorOne = getColorByKey('serviceA', createTheme());
|
const colorOne = getColorByKey('serviceA');
|
||||||
const colorTwo = getColorByKey('serviceB', createTheme());
|
const colorTwo = getColorByKey('serviceB');
|
||||||
expect(colorOne).not.toBe(colorTwo);
|
expect(colorOne).not.toBe(colorTwo);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,6 +32,6 @@ it('should not allow red', () => {
|
|||||||
clear();
|
clear();
|
||||||
// when aPAKNMeFcF is hashed it's index is 4
|
// when aPAKNMeFcF is hashed it's index is 4
|
||||||
// which is red, which we disallow because it looks like an error
|
// which is red, which we disallow because it looks like an error
|
||||||
const colorOne = getColorByKey('aPAKNMeFcF', createTheme());
|
const colorOne = getColorByKey('aPAKNMeFcF');
|
||||||
expect(colorOne).not.toBe('#E24D42');
|
expect(colorOne).not.toBe('#E24D42');
|
||||||
});
|
});
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { colors } from '@grafana/ui';
|
import { colors } from '@grafana/ui';
|
||||||
|
|
||||||
// TS needs the precise return type
|
// TS needs the precise return type
|
||||||
@ -95,10 +94,10 @@ export function clear() {
|
|||||||
getGenerator([]);
|
getGenerator([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getColorByKey(key: string, theme: GrafanaTheme2) {
|
export function getColorByKey(key: string) {
|
||||||
return getGenerator(colors).getColorByKey(key);
|
return getGenerator(colors).getColorByKey(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRgbColorByKey(key: string, theme: GrafanaTheme2): [number, number, number] {
|
export function getRgbColorByKey(key: string): [number, number, number] {
|
||||||
return getGenerator(colors).getRgbColorByKey(key);
|
return getGenerator(colors).getRgbColorByKey(key);
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,7 @@ var (
|
|||||||
// that HTTPServer needs
|
// that HTTPServer needs
|
||||||
func (hs *HTTPServer) declareFixedRoles() error {
|
func (hs *HTTPServer) declareFixedRoles() error {
|
||||||
// Declare plugins roles
|
// Declare plugins roles
|
||||||
if err := plugins.DeclareRBACRoles(hs.AccessControl); err != nil {
|
if err := plugins.DeclareRBACRoles(hs.accesscontrolService); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
|||||||
Grants: []string{"Admin"},
|
Grants: []string{"Admin"},
|
||||||
}
|
}
|
||||||
|
|
||||||
return hs.AccessControl.DeclareFixedRoles(
|
return hs.accesscontrolService.DeclareFixedRoles(
|
||||||
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
||||||
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
|
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
|
||||||
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
|
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
|
||||||
|
@ -40,6 +40,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/correlations"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@ -87,7 +88,8 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index)
|
r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index)
|
||||||
r.Get("/datasources/", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
r.Get("/datasources/", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
||||||
r.Get("/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index)
|
r.Get("/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index)
|
||||||
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index)
|
r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index)
|
||||||
|
r.Get("/datasources/correlations", authorize(reqOrgAdmin, correlations.ConfigurationPageAccess), hs.Index)
|
||||||
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
|
r.Get("/org/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), hs.Index)
|
||||||
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
|
r.Get("/org/users/new", reqOrgAdmin, hs.Index)
|
||||||
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), hs.Index)
|
r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), hs.Index)
|
||||||
|
@ -236,6 +236,7 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins Enab
|
|||||||
URL: url,
|
URL: url,
|
||||||
IsDefault: ds.IsDefault,
|
IsDefault: ds.IsDefault,
|
||||||
Access: string(ds.Access),
|
Access: string(ds.Access),
|
||||||
|
ReadOnly: ds.ReadOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin, exists := enabledPlugins.Get(plugins.DataSource, ds.Type)
|
plugin, exists := enabledPlugins.Get(plugins.DataSource, ds.Type)
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/correlations"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@ -265,6 +266,16 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasAccess(ac.ReqOrgAdmin, correlations.ConfigurationPageAccess) {
|
||||||
|
configNodes = append(configNodes, &dtos.NavLink{
|
||||||
|
Text: "Correlations",
|
||||||
|
Icon: "gf-glue",
|
||||||
|
Description: "Add and configure correlations",
|
||||||
|
Id: "correlations",
|
||||||
|
Url: hs.Cfg.AppSubURL + "/datasources/correlations",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
|
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
|
||||||
configNodes = append(configNodes, &dtos.NavLink{
|
configNodes = append(configNodes, &dtos.NavLink{
|
||||||
Text: "Users",
|
Text: "Users",
|
||||||
|
@ -13,7 +13,7 @@ var (
|
|||||||
ScopeProvider = ac.NewScopeProvider("plugins")
|
ScopeProvider = ac.NewScopeProvider("plugins")
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeclareRBACRoles(acService ac.AccessControl) error {
|
func DeclareRBACRoles(service ac.Service) error {
|
||||||
AppPluginsReader := ac.RoleRegistration{
|
AppPluginsReader := ac.RoleRegistration{
|
||||||
Role: ac.RoleDTO{
|
Role: ac.RoleDTO{
|
||||||
Name: ac.FixedRolePrefix + "plugins.app:reader",
|
Name: ac.FixedRolePrefix + "plugins.app:reader",
|
||||||
@ -26,5 +26,5 @@ func DeclareRBACRoles(acService ac.AccessControl) error {
|
|||||||
},
|
},
|
||||||
Grants: []string{string(org.RoleViewer)},
|
Grants: []string{string(org.RoleViewer)},
|
||||||
}
|
}
|
||||||
return acService.DeclareFixedRoles(AppPluginsReader)
|
return service.DeclareFixedRoles(AppPluginsReader)
|
||||||
}
|
}
|
||||||
|
@ -220,6 +220,7 @@ type DataSourceDTO struct {
|
|||||||
Preload bool `json:"preload"`
|
Preload bool `json:"preload"`
|
||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
JSONData map[string]interface{} `json:"jsonData"`
|
JSONData map[string]interface{} `json:"jsonData"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
|
||||||
BasicAuth string `json:"basicAuth,omitempty"`
|
BasicAuth string `json:"basicAuth,omitempty"`
|
||||||
WithCredentials bool `json:"withCredentials,omitempty"`
|
WithCredentials bool `json:"withCredentials,omitempty"`
|
||||||
|
@ -18,10 +18,6 @@ type AccessControl interface {
|
|||||||
// RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
|
// RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
|
||||||
// specific scope prefix (ex: datasources:name:)
|
// specific scope prefix (ex: datasources:name:)
|
||||||
RegisterScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver)
|
RegisterScopeAttributeResolver(prefix string, resolver ScopeAttributeResolver)
|
||||||
// DeclareFixedRoles allows the caller to declare, to the service, fixed roles and their
|
|
||||||
// assignments to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin"
|
|
||||||
// FIXME: Remove from access control interface and inject service where this is needed
|
|
||||||
DeclareFixedRoles(registrations ...RoleRegistration) error
|
|
||||||
//IsDisabled returns if access control is enabled or not
|
//IsDisabled returns if access control is enabled or not
|
||||||
IsDisabled() bool
|
IsDisabled() bool
|
||||||
}
|
}
|
||||||
|
@ -55,10 +55,6 @@ func (f FakeAccessControl) Evaluate(ctx context.Context, user *user.SignedInUser
|
|||||||
func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
|
func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FakeAccessControl) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistration) error {
|
|
||||||
return f.ExpectedErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f FakeAccessControl) IsDisabled() bool {
|
func (f FakeAccessControl) IsDisabled() bool {
|
||||||
return f.ExpectedDisabled
|
return f.ExpectedDisabled
|
||||||
}
|
}
|
||||||
|
@ -66,11 +66,6 @@ func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver a
|
|||||||
a.resolvers.AddScopeAttributeResolver(prefix, resolver)
|
a.resolvers.AddScopeAttributeResolver(prefix, resolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AccessControl) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistration) error {
|
|
||||||
// FIXME: Remove wrapped call
|
|
||||||
return a.service.DeclareFixedRoles(registrations...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AccessControl) IsDisabled() bool {
|
func (a *AccessControl) IsDisabled() bool {
|
||||||
return accesscontrol.IsDisabled(a.cfg)
|
return accesscontrol.IsDisabled(a.cfg)
|
||||||
}
|
}
|
||||||
|
11
pkg/services/correlations/accesscontrol.go
Normal file
11
pkg/services/correlations/accesscontrol.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package correlations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ConfigurationPageAccess is used to protect the "Configure > correlations" tab access
|
||||||
|
ConfigurationPageAccess = accesscontrol.EvalPermission(datasources.ActionRead)
|
||||||
|
)
|
@ -173,8 +173,8 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeclareFixedRoles(ac accesscontrol.AccessControl) error {
|
func DeclareFixedRoles(service accesscontrol.Service) error {
|
||||||
return ac.DeclareFixedRoles(
|
return service.DeclareFixedRoles(
|
||||||
rulesReaderRole, rulesWriterRole,
|
rulesReaderRole, rulesWriterRole,
|
||||||
instancesReaderRole, instancesWriterRole,
|
instancesReaderRole, instancesWriterRole,
|
||||||
notificationsReaderRole, notificationsWriterRole,
|
notificationsReaderRole, notificationsWriterRole,
|
||||||
|
@ -22,7 +22,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||||
"github.com/grafana/grafana/pkg/services/quota"
|
"github.com/grafana/grafana/pkg/services/quota"
|
||||||
"github.com/grafana/grafana/pkg/services/secrets"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -76,7 +75,6 @@ type API struct {
|
|||||||
DataProxy *datasourceproxy.DataSourceProxyService
|
DataProxy *datasourceproxy.DataSourceProxyService
|
||||||
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
||||||
StateManager *state.Manager
|
StateManager *state.Manager
|
||||||
SecretsService secrets.Service
|
|
||||||
AccessControl accesscontrol.AccessControl
|
AccessControl accesscontrol.AccessControl
|
||||||
Policies *provisioning.NotificationPolicyService
|
Policies *provisioning.NotificationPolicyService
|
||||||
ContactPointService *provisioning.ContactPointService
|
ContactPointService *provisioning.ContactPointService
|
||||||
@ -128,7 +126,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
|||||||
DatasourceCache: api.DatasourceCache,
|
DatasourceCache: api.DatasourceCache,
|
||||||
log: logger,
|
log: logger,
|
||||||
accessControl: api.AccessControl,
|
accessControl: api.AccessControl,
|
||||||
evaluator: eval.NewEvaluator(api.Cfg, log.New("ngalert.eval"), api.DatasourceCache, api.SecretsService, api.ExpressionService),
|
evaluator: eval.NewEvaluator(api.Cfg, log.New("ngalert.eval"), api.DatasourceCache, api.ExpressionService),
|
||||||
}), m)
|
}), m)
|
||||||
api.RegisterConfigurationApiEndpoints(NewConfiguration(
|
api.RegisterConfigurationApiEndpoints(NewConfiguration(
|
||||||
&ConfigSrv{
|
&ConfigSrv{
|
||||||
|
@ -11,14 +11,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
"github.com/grafana/grafana/pkg/expr"
|
"github.com/grafana/grafana/pkg/expr"
|
||||||
"github.com/grafana/grafana/pkg/expr/classic"
|
"github.com/grafana/grafana/pkg/expr/classic"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/secrets"
|
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
|
||||||
@ -38,7 +36,6 @@ type evaluatorImpl struct {
|
|||||||
cfg *setting.Cfg
|
cfg *setting.Cfg
|
||||||
log log.Logger
|
log log.Logger
|
||||||
dataSourceCache datasources.CacheService
|
dataSourceCache datasources.CacheService
|
||||||
secretsService secrets.Service
|
|
||||||
expressionService *expr.Service
|
expressionService *expr.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,13 +43,11 @@ func NewEvaluator(
|
|||||||
cfg *setting.Cfg,
|
cfg *setting.Cfg,
|
||||||
log log.Logger,
|
log log.Logger,
|
||||||
datasourceCache datasources.CacheService,
|
datasourceCache datasources.CacheService,
|
||||||
secretsService secrets.Service,
|
|
||||||
expressionService *expr.Service) Evaluator {
|
expressionService *expr.Service) Evaluator {
|
||||||
return &evaluatorImpl{
|
return &evaluatorImpl{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
dataSourceCache: datasourceCache,
|
dataSourceCache: datasourceCache,
|
||||||
secretsService: secretsService,
|
|
||||||
expressionService: expressionService,
|
expressionService: expressionService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,7 +159,7 @@ type AlertExecCtx struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getExprRequest validates the condition, gets the datasource information and creates an expr.Request from it.
|
// getExprRequest validates the condition, gets the datasource information and creates an expr.Request from it.
|
||||||
func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, dsCacheService datasources.CacheService, secretsService secrets.Service) (*expr.Request, error) {
|
func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, dsCacheService datasources.CacheService) (*expr.Request, error) {
|
||||||
req := &expr.Request{
|
req := &expr.Request{
|
||||||
OrgId: ctx.OrgID,
|
OrgId: ctx.OrgID,
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
@ -207,19 +202,6 @@ func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, d
|
|||||||
datasources[q.DatasourceUID] = ds
|
datasources[q.DatasourceUID] = ds
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the datasource has been configured with custom HTTP headers
|
|
||||||
// then we need to add these to the request
|
|
||||||
decryptedData, err := secretsService.DecryptJsonData(ctx.Ctx, ds.SecureJsonData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
customHeaders := getCustomHeaders(ds.JsonData, decryptedData)
|
|
||||||
for k, v := range customHeaders {
|
|
||||||
if _, ok := req.Headers[k]; !ok {
|
|
||||||
req.Headers[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Queries = append(req.Queries, expr.Query{
|
req.Queries = append(req.Queries, expr.Query{
|
||||||
TimeRange: expr.TimeRange{
|
TimeRange: expr.TimeRange{
|
||||||
From: q.RelativeTimeRange.ToTimeRange(now).From,
|
From: q.RelativeTimeRange.ToTimeRange(now).From,
|
||||||
@ -236,32 +218,6 @@ func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, d
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCustomHeaders(jsonData *simplejson.Json, decryptedValues map[string]string) map[string]string {
|
|
||||||
headers := make(map[string]string)
|
|
||||||
if jsonData == nil {
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
index := 1
|
|
||||||
for {
|
|
||||||
headerNameSuffix := fmt.Sprintf("httpHeaderName%d", index)
|
|
||||||
headerValueSuffix := fmt.Sprintf("httpHeaderValue%d", index)
|
|
||||||
|
|
||||||
key := jsonData.Get(headerNameSuffix).MustString()
|
|
||||||
if key == "" {
|
|
||||||
// No (more) header values are available
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if val, ok := decryptedValues[headerValueSuffix]; ok {
|
|
||||||
headers[key] = val
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
type NumberValueCapture struct {
|
type NumberValueCapture struct {
|
||||||
Var string // RefID
|
Var string // RefID
|
||||||
Labels data.Labels
|
Labels data.Labels
|
||||||
@ -347,7 +303,7 @@ func queryDataResponseToExecutionResults(c models.Condition, execResp *backend.Q
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeQueriesAndExpressions(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, exprService *expr.Service, dsCacheService datasources.CacheService, secretsService secrets.Service) (resp *backend.QueryDataResponse, err error) {
|
func executeQueriesAndExpressions(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, exprService *expr.Service, dsCacheService datasources.CacheService) (resp *backend.QueryDataResponse, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if e := recover(); e != nil {
|
if e := recover(); e != nil {
|
||||||
ctx.Log.Error("alert rule panic", "error", e, "stack", string(debug.Stack()))
|
ctx.Log.Error("alert rule panic", "error", e, "stack", string(debug.Stack()))
|
||||||
@ -360,7 +316,7 @@ func executeQueriesAndExpressions(ctx AlertExecCtx, data []models.AlertQuery, no
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
queryDataReq, err := getExprRequest(ctx, data, now, dsCacheService, secretsService)
|
queryDataReq, err := getExprRequest(ctx, data, now, dsCacheService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -611,7 +567,7 @@ func (e *evaluatorImpl) QueriesAndExpressionsEval(ctx context.Context, orgID int
|
|||||||
|
|
||||||
alertExecCtx := AlertExecCtx{OrgID: orgID, Ctx: alertCtx, ExpressionsEnabled: e.cfg.ExpressionsEnabled, Log: e.log}
|
alertExecCtx := AlertExecCtx{OrgID: orgID, Ctx: alertCtx, ExpressionsEnabled: e.cfg.ExpressionsEnabled, Log: e.log}
|
||||||
|
|
||||||
execResult, err := executeQueriesAndExpressions(alertExecCtx, data, now, e.expressionService, e.dataSourceCache, e.secretsService)
|
execResult, err := executeQueriesAndExpressions(alertExecCtx, data, now, e.expressionService, e.dataSourceCache)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to execute conditions: %w", err)
|
return nil, fmt.Errorf("failed to execute conditions: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -41,26 +41,27 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
|
|||||||
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
|
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
|
||||||
quotaService quota.Service, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert,
|
quotaService quota.Service, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert,
|
||||||
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, renderService rendering.Service,
|
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, renderService rendering.Service,
|
||||||
bus bus.Bus) (*AlertNG, error) {
|
bus bus.Bus, accesscontrolService accesscontrol.Service) (*AlertNG, error) {
|
||||||
ng := &AlertNG{
|
ng := &AlertNG{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
DataSourceCache: dataSourceCache,
|
DataSourceCache: dataSourceCache,
|
||||||
DataSourceService: dataSourceService,
|
DataSourceService: dataSourceService,
|
||||||
RouteRegister: routeRegister,
|
RouteRegister: routeRegister,
|
||||||
SQLStore: sqlStore,
|
SQLStore: sqlStore,
|
||||||
KVStore: kvStore,
|
KVStore: kvStore,
|
||||||
ExpressionService: expressionService,
|
ExpressionService: expressionService,
|
||||||
DataProxy: dataProxy,
|
DataProxy: dataProxy,
|
||||||
QuotaService: quotaService,
|
QuotaService: quotaService,
|
||||||
SecretsService: secretsService,
|
SecretsService: secretsService,
|
||||||
Metrics: m,
|
Metrics: m,
|
||||||
Log: log.New("ngalert"),
|
Log: log.New("ngalert"),
|
||||||
NotificationService: notificationService,
|
NotificationService: notificationService,
|
||||||
folderService: folderService,
|
folderService: folderService,
|
||||||
accesscontrol: ac,
|
accesscontrol: ac,
|
||||||
dashboardService: dashboardService,
|
dashboardService: dashboardService,
|
||||||
renderService: renderService,
|
renderService: renderService,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
|
accesscontrolService: accesscontrolService,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ng.IsDisabled() {
|
if ng.IsDisabled() {
|
||||||
@ -100,6 +101,7 @@ type AlertNG struct {
|
|||||||
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
||||||
AlertsRouter *sender.AlertsRouter
|
AlertsRouter *sender.AlertsRouter
|
||||||
accesscontrol accesscontrol.AccessControl
|
accesscontrol accesscontrol.AccessControl
|
||||||
|
accesscontrolService accesscontrol.Service
|
||||||
|
|
||||||
bus bus.Bus
|
bus bus.Bus
|
||||||
}
|
}
|
||||||
@ -156,7 +158,7 @@ func (ng *AlertNG) init() error {
|
|||||||
Cfg: ng.Cfg.UnifiedAlerting,
|
Cfg: ng.Cfg.UnifiedAlerting,
|
||||||
C: clk,
|
C: clk,
|
||||||
Logger: ng.Log,
|
Logger: ng.Log,
|
||||||
Evaluator: eval.NewEvaluator(ng.Cfg, ng.Log, ng.DataSourceCache, ng.SecretsService, ng.ExpressionService),
|
Evaluator: eval.NewEvaluator(ng.Cfg, ng.Log, ng.DataSourceCache, ng.ExpressionService),
|
||||||
InstanceStore: store,
|
InstanceStore: store,
|
||||||
RuleStore: store,
|
RuleStore: store,
|
||||||
Metrics: ng.Metrics.GetSchedulerMetrics(),
|
Metrics: ng.Metrics.GetSchedulerMetrics(),
|
||||||
@ -192,7 +194,6 @@ func (ng *AlertNG) init() error {
|
|||||||
Schedule: ng.schedule,
|
Schedule: ng.schedule,
|
||||||
DataProxy: ng.DataProxy,
|
DataProxy: ng.DataProxy,
|
||||||
QuotaService: ng.QuotaService,
|
QuotaService: ng.QuotaService,
|
||||||
SecretsService: ng.SecretsService,
|
|
||||||
TransactionManager: store,
|
TransactionManager: store,
|
||||||
InstanceStore: store,
|
InstanceStore: store,
|
||||||
RuleStore: store,
|
RuleStore: store,
|
||||||
@ -211,7 +212,7 @@ func (ng *AlertNG) init() error {
|
|||||||
}
|
}
|
||||||
api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
|
api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
|
||||||
|
|
||||||
return DeclareFixedRoles(ng.accesscontrol)
|
return DeclareFixedRoles(ng.accesscontrolService)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore store.RuleStore, scheduler schedule.ScheduleService) {
|
func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore store.RuleStore, scheduler schedule.ScheduleService) {
|
||||||
|
@ -30,8 +30,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
|
||||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
@ -501,8 +499,7 @@ func setupScheduler(t *testing.T, rs *store.FakeRuleStore, is *store.FakeInstanc
|
|||||||
|
|
||||||
var evaluator eval.Evaluator = evalMock
|
var evaluator eval.Evaluator = evalMock
|
||||||
if evalMock == nil {
|
if evalMock == nil {
|
||||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
evaluator = eval.NewEvaluator(&setting.Cfg{ExpressionsEnabled: true}, logger, nil, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil))
|
||||||
evaluator = eval.NewEvaluator(&setting.Cfg{ExpressionsEnabled: true}, logger, nil, secretsService, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
|
@ -64,7 +64,7 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, *
|
|||||||
|
|
||||||
ng, err := ngalert.ProvideService(
|
ng, err := ngalert.ProvideService(
|
||||||
cfg, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil,
|
cfg, nil, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil,
|
||||||
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus,
|
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac,
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return ng, &store.DBstore{
|
return ng, &store.DBstore{
|
||||||
|
@ -54,6 +54,7 @@ func ProvideApi(
|
|||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Registers Endpoints on Grafana Router
|
||||||
func (api *Api) RegisterAPIEndpoints() {
|
func (api *Api) RegisterAPIEndpoints() {
|
||||||
auth := accesscontrol.Middleware(api.AccessControl)
|
auth := accesscontrol.Middleware(api.AccessControl)
|
||||||
reqSignedIn := middleware.ReqSignedIn
|
reqSignedIn := middleware.ReqSignedIn
|
||||||
@ -70,20 +71,20 @@ func (api *Api) RegisterAPIEndpoints() {
|
|||||||
api.RouteRegister.Post("/api/dashboards/uid/:uid/public-config", auth(reqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(api.SavePublicDashboardConfig))
|
api.RouteRegister.Post("/api/dashboards/uid/:uid/public-config", auth(reqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(api.SavePublicDashboardConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
// gets public dashboard
|
// Gets public dashboard
|
||||||
|
// GET /api/public/dashboards/:accessToken
|
||||||
func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
||||||
accessToken := web.Params(c.Req)[":accessToken"]
|
accessToken := web.Params(c.Req)[":accessToken"]
|
||||||
|
|
||||||
dash, err := api.PublicDashboardService.GetPublicDashboard(c.Req.Context(), accessToken)
|
pubdash, dash, err := api.PublicDashboardService.GetPublicDashboard(
|
||||||
|
c.Req.Context(),
|
||||||
|
web.Params(c.Req)[":accessToken"],
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err)
|
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubDash, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dash.OrgId, dash.Uid)
|
|
||||||
if err != nil {
|
|
||||||
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := dtos.DashboardMeta{
|
meta := dtos.DashboardMeta{
|
||||||
Slug: dash.Slug,
|
Slug: dash.Slug,
|
||||||
Type: models.DashTypeDB,
|
Type: models.DashTypeDB,
|
||||||
@ -98,7 +99,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
|||||||
IsFolder: false,
|
IsFolder: false,
|
||||||
FolderId: dash.FolderId,
|
FolderId: dash.FolderId,
|
||||||
PublicDashboardAccessToken: accessToken,
|
PublicDashboardAccessToken: accessToken,
|
||||||
PublicDashboardUID: pubDash.Uid,
|
PublicDashboardUID: pubdash.Uid,
|
||||||
}
|
}
|
||||||
|
|
||||||
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}
|
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}
|
||||||
@ -106,7 +107,8 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
|||||||
return response.JSON(http.StatusOK, dto)
|
return response.JSON(http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// gets public dashboard configuration for dashboard
|
// Gets public dashboard configuration for dashboard
|
||||||
|
// GET /api/dashboards/uid/:uid/public-config
|
||||||
func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response {
|
func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response {
|
||||||
pdc, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgID, web.Params(c.Req)[":uid"])
|
pdc, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgID, web.Params(c.Req)[":uid"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -115,7 +117,8 @@ func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response
|
|||||||
return response.JSON(http.StatusOK, pdc)
|
return response.JSON(http.StatusOK, pdc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sets public dashboard configuration for dashboard
|
// Sets public dashboard configuration for dashboard
|
||||||
|
// POST /api/dashboards/uid/:uid/public-config
|
||||||
func (api *Api) SavePublicDashboardConfig(c *models.ReqContext) response.Response {
|
func (api *Api) SavePublicDashboardConfig(c *models.ReqContext) response.Response {
|
||||||
pubdash := &PublicDashboard{}
|
pubdash := &PublicDashboard{}
|
||||||
if err := web.Bind(c.Req, pubdash); err != nil {
|
if err := web.Bind(c.Req, pubdash); err != nil {
|
||||||
@ -149,32 +152,30 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response {
|
|||||||
return response.Error(http.StatusBadRequest, "invalid panel ID", err)
|
return response.Error(http.StatusBadRequest, "invalid panel ID", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dashboard, err := api.PublicDashboardService.GetPublicDashboard(c.Req.Context(), web.Params(c.Req)[":accessToken"])
|
// Get the dashboard
|
||||||
|
pubdash, dashboard, err := api.PublicDashboardService.GetPublicDashboard(c.Req.Context(), web.Params(c.Req)[":accessToken"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(http.StatusInternalServerError, "could not fetch dashboard", err)
|
return response.Error(http.StatusInternalServerError, "could not fetch dashboard", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
publicDashboard, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dashboard.OrgId, dashboard.Uid)
|
// Build the request data objecct
|
||||||
if err != nil {
|
|
||||||
return response.Error(http.StatusInternalServerError, "could not fetch public dashboard", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqDTO, err := api.PublicDashboardService.BuildPublicDashboardMetricRequest(
|
reqDTO, err := api.PublicDashboardService.BuildPublicDashboardMetricRequest(
|
||||||
c.Req.Context(),
|
c.Req.Context(),
|
||||||
dashboard,
|
dashboard,
|
||||||
publicDashboard,
|
pubdash,
|
||||||
panelId,
|
panelId,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return handleDashboardErr(http.StatusInternalServerError, "Failed to get queries for public dashboard", err)
|
return handleDashboardErr(http.StatusInternalServerError, "Failed to get queries for public dashboard", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build anonymous user for the request
|
||||||
anonymousUser, err := api.PublicDashboardService.BuildAnonymousUser(c.Req.Context(), dashboard)
|
anonymousUser, err := api.PublicDashboardService.BuildAnonymousUser(c.Req.Context(), dashboard)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.Error(http.StatusInternalServerError, "could not create anonymous user", err)
|
return response.Error(http.StatusInternalServerError, "could not create anonymous user", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make the request
|
||||||
resp, err := api.QueryDataService.QueryDataMultipleSources(c.Req.Context(), anonymousUser, c.SkipCache, reqDTO, true)
|
resp, err := api.QueryDataService.QueryDataMultipleSources(c.Req.Context(), anonymousUser, c.SkipCache, reqDTO, true)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -43,10 +43,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
qs := buildQueryDataService(t, nil, nil, nil)
|
qs := buildQueryDataService(t, nil, nil, nil)
|
||||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||||
Return(&models.Dashboard{}, nil).Maybe()
|
Return(&PublicDashboard{}, &models.Dashboard{}, nil).Maybe()
|
||||||
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
|
||||||
Return(&PublicDashboard{}, nil).Maybe()
|
|
||||||
|
|
||||||
testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil)
|
testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil)
|
||||||
|
|
||||||
response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t)
|
response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t)
|
||||||
@ -67,29 +64,29 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
accessToken := fmt.Sprintf("%x", token)
|
accessToken := fmt.Sprintf("%x", token)
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
Name string
|
Name string
|
||||||
AccessToken string
|
AccessToken string
|
||||||
ExpectedHttpResponse int
|
ExpectedHttpResponse int
|
||||||
PublicDashboardResult *models.Dashboard
|
DashboardResult *models.Dashboard
|
||||||
PublicDashboardErr error
|
Err error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "It gets a public dashboard",
|
Name: "It gets a public dashboard",
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
ExpectedHttpResponse: http.StatusOK,
|
ExpectedHttpResponse: http.StatusOK,
|
||||||
PublicDashboardResult: &models.Dashboard{
|
DashboardResult: &models.Dashboard{
|
||||||
Data: simplejson.NewFromAny(map[string]interface{}{
|
Data: simplejson.NewFromAny(map[string]interface{}{
|
||||||
"Uid": DashboardUid,
|
"Uid": DashboardUid,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
PublicDashboardErr: nil,
|
Err: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "It should return 404 if no public dashboard",
|
Name: "It should return 404 if no public dashboard",
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
ExpectedHttpResponse: http.StatusNotFound,
|
ExpectedHttpResponse: http.StatusNotFound,
|
||||||
PublicDashboardResult: nil,
|
DashboardResult: nil,
|
||||||
PublicDashboardErr: ErrPublicDashboardNotFound,
|
Err: ErrPublicDashboardNotFound,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +94,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
t.Run(test.Name, func(t *testing.T) {
|
t.Run(test.Name, func(t *testing.T) {
|
||||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||||
Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe()
|
Return(&PublicDashboard{}, test.DashboardResult, test.Err).Maybe()
|
||||||
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
|
||||||
Return(&PublicDashboard{}, nil).Maybe()
|
|
||||||
|
|
||||||
cfg := setting.NewCfg()
|
cfg := setting.NewCfg()
|
||||||
cfg.RBACEnabled = false
|
cfg.RBACEnabled = false
|
||||||
@ -121,7 +116,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||||
|
|
||||||
if test.PublicDashboardErr == nil {
|
if test.Err == nil {
|
||||||
var dashResp dtos.DashboardFullWithMeta
|
var dashResp dtos.DashboardFullWithMeta
|
||||||
err := json.Unmarshal(response.Body.Bytes(), &dashResp)
|
err := json.Unmarshal(response.Body.Bytes(), &dashResp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -136,7 +131,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
err := json.Unmarshal(response.Body.Bytes(), &errResp)
|
err := json.Unmarshal(response.Body.Bytes(), &errResp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, test.PublicDashboardErr.Error(), errResp.Error)
|
assert.Equal(t, test.Err.Error(), errResp.Error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -349,8 +344,7 @@ func TestAPIQueryPublicDashboard(t *testing.T) {
|
|||||||
t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) {
|
t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) {
|
||||||
server, fakeDashboardService := setup(true)
|
server, fakeDashboardService := setup(true)
|
||||||
|
|
||||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, nil)
|
||||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
|
||||||
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
||||||
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
||||||
Queries: []*simplejson.Json{
|
Queries: []*simplejson.Json{
|
||||||
@ -400,8 +394,7 @@ func TestAPIQueryPublicDashboard(t *testing.T) {
|
|||||||
t.Run("Status code is 500 when the query fails", func(t *testing.T) {
|
t.Run("Status code is 500 when the query fails", func(t *testing.T) {
|
||||||
server, fakeDashboardService := setup(true)
|
server, fakeDashboardService := setup(true)
|
||||||
|
|
||||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, nil)
|
||||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
|
||||||
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
||||||
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
||||||
Queries: []*simplejson.Json{
|
Queries: []*simplejson.Json{
|
||||||
@ -430,8 +423,7 @@ func TestAPIQueryPublicDashboard(t *testing.T) {
|
|||||||
t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) {
|
t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) {
|
||||||
server, fakeDashboardService := setup(true)
|
server, fakeDashboardService := setup(true)
|
||||||
|
|
||||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, nil)
|
||||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
|
||||||
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&user.SignedInUser{}, nil)
|
||||||
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{
|
||||||
Queries: []*simplejson.Json{
|
Queries: []*simplejson.Json{
|
||||||
|
@ -9,11 +9,12 @@ import (
|
|||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
models "github.com/grafana/grafana/pkg/models"
|
models "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
|
||||||
|
|
||||||
publicdashboardsmodels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
publicdashboardsmodels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||||
|
|
||||||
testing "testing"
|
testing "testing"
|
||||||
|
|
||||||
|
user "github.com/grafana/grafana/pkg/services/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FakePublicDashboardService is an autogenerated mock type for the Service type
|
// FakePublicDashboardService is an autogenerated mock type for the Service type
|
||||||
@ -110,26 +111,35 @@ func (_m *FakePublicDashboardService) GetDashboard(ctx context.Context, dashboar
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicDashboard provides a mock function with given fields: ctx, accessToken
|
// GetPublicDashboard provides a mock function with given fields: ctx, accessToken
|
||||||
func (_m *FakePublicDashboardService) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) {
|
func (_m *FakePublicDashboardService) GetPublicDashboard(ctx context.Context, accessToken string) (*publicdashboardsmodels.PublicDashboard, *models.Dashboard, error) {
|
||||||
ret := _m.Called(ctx, accessToken)
|
ret := _m.Called(ctx, accessToken)
|
||||||
|
|
||||||
var r0 *models.Dashboard
|
var r0 *publicdashboardsmodels.PublicDashboard
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, string) *models.Dashboard); ok {
|
if rf, ok := ret.Get(0).(func(context.Context, string) *publicdashboardsmodels.PublicDashboard); ok {
|
||||||
r0 = rf(ctx, accessToken)
|
r0 = rf(ctx, accessToken)
|
||||||
} else {
|
} else {
|
||||||
if ret.Get(0) != nil {
|
if ret.Get(0) != nil {
|
||||||
r0 = ret.Get(0).(*models.Dashboard)
|
r0 = ret.Get(0).(*publicdashboardsmodels.PublicDashboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var r1 error
|
var r1 *models.Dashboard
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
if rf, ok := ret.Get(1).(func(context.Context, string) *models.Dashboard); ok {
|
||||||
r1 = rf(ctx, accessToken)
|
r1 = rf(ctx, accessToken)
|
||||||
} else {
|
} else {
|
||||||
r1 = ret.Error(1)
|
if ret.Get(1) != nil {
|
||||||
|
r1 = ret.Get(1).(*models.Dashboard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r0, r1
|
var r2 error
|
||||||
|
if rf, ok := ret.Get(2).(func(context.Context, string) error); ok {
|
||||||
|
r2 = rf(ctx, accessToken)
|
||||||
|
} else {
|
||||||
|
r2 = ret.Error(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1, r2
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid
|
// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid
|
||||||
|
@ -14,7 +14,7 @@ import (
|
|||||||
//go:generate mockery --name Service --structname FakePublicDashboardService --inpackage --filename public_dashboard_service_mock.go
|
//go:generate mockery --name Service --structname FakePublicDashboardService --inpackage --filename public_dashboard_service_mock.go
|
||||||
type Service interface {
|
type Service interface {
|
||||||
BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*user.SignedInUser, error)
|
BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*user.SignedInUser, error)
|
||||||
GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error)
|
GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error)
|
||||||
GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error)
|
GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error)
|
||||||
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error)
|
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error)
|
||||||
SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
|
SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
|
||||||
|
@ -56,26 +56,22 @@ func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboar
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gets public dashboard via access token
|
// Gets public dashboard via access token
|
||||||
func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) {
|
func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) {
|
||||||
pubdash, d, err := pd.store.GetPublicDashboard(ctx, accessToken)
|
pubdash, dash, err := pd.store.GetPublicDashboard(ctx, accessToken)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if pubdash == nil || d == nil {
|
if pubdash == nil || dash == nil {
|
||||||
return nil, ErrPublicDashboardNotFound
|
return nil, nil, ErrPublicDashboardNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pubdash.IsEnabled {
|
if !pubdash.IsEnabled {
|
||||||
return nil, ErrPublicDashboardNotFound
|
return nil, nil, ErrPublicDashboardNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := pubdash.BuildTimeSettings(d)
|
return pubdash, dash, nil
|
||||||
d.Data.SetPath([]string{"time", "from"}, ts.From)
|
|
||||||
d.Data.SetPath([]string{"time", "to"}, ts.To)
|
|
||||||
|
|
||||||
return d, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicDashboardConfig is a helper method to retrieve the public dashboard configuration for a given dashboard from the database
|
// GetPublicDashboardConfig is a helper method to retrieve the public dashboard configuration for a given dashboard from the database
|
||||||
|
@ -25,7 +25,6 @@ import (
|
|||||||
var timeSettings, _ = simplejson.NewJson([]byte(`{"from": "now-12", "to": "now"}`))
|
var timeSettings, _ = simplejson.NewJson([]byte(`{"from": "now-12", "to": "now"}`))
|
||||||
var defaultPubdashTimeSettings, _ = simplejson.NewJson([]byte(`{}`))
|
var defaultPubdashTimeSettings, _ = simplejson.NewJson([]byte(`{}`))
|
||||||
var dashboardData = simplejson.NewFromAny(map[string]interface{}{"time": map[string]interface{}{"from": "now-8", "to": "now"}})
|
var dashboardData = simplejson.NewFromAny(map[string]interface{}{"time": map[string]interface{}{"from": "now-8", "to": "now"}})
|
||||||
var mergedDashboardData = simplejson.NewFromAny(map[string]interface{}{"time": map[string]interface{}{"from": "now-12", "to": "now"}})
|
|
||||||
|
|
||||||
func TestLogPrefix(t *testing.T) {
|
func TestLogPrefix(t *testing.T) {
|
||||||
assert.Equal(t, LogPrefix, "publicdashboards.service")
|
assert.Equal(t, LogPrefix, "publicdashboards.service")
|
||||||
@ -49,29 +48,18 @@ func TestGetPublicDashboard(t *testing.T) {
|
|||||||
Name: "returns a dashboard",
|
Name: "returns a dashboard",
|
||||||
AccessToken: "abc123",
|
AccessToken: "abc123",
|
||||||
StoreResp: &storeResp{
|
StoreResp: &storeResp{
|
||||||
pd: &PublicDashboard{IsEnabled: true},
|
pd: &PublicDashboard{AccessToken: "abcdToken", IsEnabled: true},
|
||||||
d: &models.Dashboard{Uid: "mydashboard", Data: dashboardData},
|
d: &models.Dashboard{Uid: "mydashboard", Data: dashboardData},
|
||||||
err: nil,
|
err: nil,
|
||||||
},
|
},
|
||||||
ErrResp: nil,
|
ErrResp: nil,
|
||||||
DashResp: &models.Dashboard{Uid: "mydashboard", Data: dashboardData},
|
DashResp: &models.Dashboard{Uid: "mydashboard", Data: dashboardData},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "puts pubdash time settings into dashboard",
|
|
||||||
AccessToken: "abc123",
|
|
||||||
StoreResp: &storeResp{
|
|
||||||
pd: &PublicDashboard{IsEnabled: true, TimeSettings: timeSettings},
|
|
||||||
d: &models.Dashboard{Data: dashboardData},
|
|
||||||
err: nil,
|
|
||||||
},
|
|
||||||
ErrResp: nil,
|
|
||||||
DashResp: &models.Dashboard{Data: mergedDashboardData},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "returns ErrPublicDashboardNotFound when isEnabled is false",
|
Name: "returns ErrPublicDashboardNotFound when isEnabled is false",
|
||||||
AccessToken: "abc123",
|
AccessToken: "abc123",
|
||||||
StoreResp: &storeResp{
|
StoreResp: &storeResp{
|
||||||
pd: &PublicDashboard{IsEnabled: false},
|
pd: &PublicDashboard{AccessToken: "abcdToken", IsEnabled: false},
|
||||||
d: &models.Dashboard{Uid: "mydashboard"},
|
d: &models.Dashboard{Uid: "mydashboard"},
|
||||||
err: nil,
|
err: nil,
|
||||||
},
|
},
|
||||||
@ -105,17 +93,18 @@ func TestGetPublicDashboard(t *testing.T) {
|
|||||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.Anything).
|
fakeStore.On("GetPublicDashboard", mock.Anything, mock.Anything).
|
||||||
Return(test.StoreResp.pd, test.StoreResp.d, test.StoreResp.err)
|
Return(test.StoreResp.pd, test.StoreResp.d, test.StoreResp.err)
|
||||||
|
|
||||||
dashboard, err := service.GetPublicDashboard(context.Background(), test.AccessToken)
|
pdc, dash, err := service.GetPublicDashboard(context.Background(), test.AccessToken)
|
||||||
if test.ErrResp != nil {
|
if test.ErrResp != nil {
|
||||||
assert.Error(t, test.ErrResp, err)
|
assert.Error(t, test.ErrResp, err)
|
||||||
} else {
|
} else {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, test.DashResp, dashboard)
|
assert.Equal(t, test.DashResp, dash)
|
||||||
|
|
||||||
if test.DashResp != nil {
|
if test.DashResp != nil {
|
||||||
assert.NotNil(t, dashboard.CreatedBy)
|
assert.NotNil(t, dash.CreatedBy)
|
||||||
|
assert.Equal(t, test.StoreResp.pd, pdc)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
@ -27,11 +26,6 @@ import (
|
|||||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
headerName = "httpHeaderName"
|
|
||||||
headerValue = "httpHeaderValue"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ProvideService(
|
func ProvideService(
|
||||||
cfg *setting.Cfg,
|
cfg *setting.Cfg,
|
||||||
dataSourceCache datasources.CacheService,
|
dataSourceCache datasources.CacheService,
|
||||||
@ -185,10 +179,6 @@ func (s *Service) handleQueryData(ctx context.Context, user *user.SignedInUser,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range customHeaders(ds.JsonData, instanceSettings.DecryptedSecureJSONData) {
|
|
||||||
req.Headers[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsedReq.httpRequest != nil {
|
if parsedReq.httpRequest != nil {
|
||||||
proxyutil.ClearCookieHeader(parsedReq.httpRequest, ds.AllowedCookies())
|
proxyutil.ClearCookieHeader(parsedReq.httpRequest, ds.AllowedCookies())
|
||||||
if cookieStr := parsedReq.httpRequest.Header.Get("Cookie"); cookieStr != "" {
|
if cookieStr := parsedReq.httpRequest.Header.Get("Cookie"); cookieStr != "" {
|
||||||
@ -216,26 +206,6 @@ type parsedRequest struct {
|
|||||||
httpRequest *http.Request
|
httpRequest *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
func customHeaders(jsonData *simplejson.Json, decryptedJsonData map[string]string) map[string]string {
|
|
||||||
if jsonData == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data := jsonData.MustMap()
|
|
||||||
|
|
||||||
headers := map[string]string{}
|
|
||||||
for k := range data {
|
|
||||||
if strings.HasPrefix(k, headerName) {
|
|
||||||
if header, ok := data[k].(string); ok {
|
|
||||||
valueKey := strings.ReplaceAll(k, headerName, headerValue)
|
|
||||||
headers[header] = decryptedJsonData[valueKey]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest) (*parsedRequest, error) {
|
func (s *Service) parseMetricRequest(ctx context.Context, user *user.SignedInUser, skipCache bool, reqDTO dtos.MetricRequest) (*parsedRequest, error) {
|
||||||
if len(reqDTO.Queries) == 0 {
|
if len(reqDTO.Queries) == 0 {
|
||||||
return nil, NewErrBadQuery("no queries found")
|
return nil, NewErrBadQuery("no queries found")
|
||||||
|
@ -2,7 +2,6 @@ package query_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -27,22 +26,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestQueryData(t *testing.T) {
|
func TestQueryData(t *testing.T) {
|
||||||
t.Run("it attaches custom headers to the request", func(t *testing.T) {
|
|
||||||
tc := setup(t)
|
|
||||||
tc.dataSourceCache.ds.JsonData = simplejson.NewFromAny(map[string]interface{}{"httpHeaderName1": "foo", "httpHeaderName2": "bar"})
|
|
||||||
|
|
||||||
secureJsonData, err := json.Marshal(map[string]string{"httpHeaderValue1": "test-header", "httpHeaderValue2": "test-header2"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = tc.secretStore.Set(context.Background(), tc.dataSourceCache.ds.OrgId, tc.dataSourceCache.ds.Name, "datasource", string(secureJsonData))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = tc.queryService.QueryData(context.Background(), nil, true, metricRequest(), false)
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
require.Equal(t, map[string]string{"foo": "test-header", "bar": "test-header2"}, tc.pluginContext.req.Headers)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("it auth custom headers to the request", func(t *testing.T) {
|
t.Run("it auth custom headers to the request", func(t *testing.T) {
|
||||||
token := &oauth2.Token{
|
token := &oauth2.Token{
|
||||||
TokenType: "bearer",
|
TokenType: "bearer",
|
||||||
|
@ -81,30 +81,78 @@ func (i *orgIndex) readerForIndex(idxType indexType) (*bluge.Reader, func(), err
|
|||||||
}
|
}
|
||||||
|
|
||||||
type searchIndex struct {
|
type searchIndex struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
loader dashboardLoader
|
loader dashboardLoader
|
||||||
perOrgIndex map[int64]*orgIndex
|
perOrgIndex map[int64]*orgIndex
|
||||||
eventStore eventStore
|
initializedOrgs map[int64]bool
|
||||||
logger log.Logger
|
initialIndexingComplete bool
|
||||||
buildSignals chan buildSignal
|
initializationMutex sync.RWMutex
|
||||||
extender DocumentExtender
|
eventStore eventStore
|
||||||
folderIdLookup folderUIDLookup
|
logger log.Logger
|
||||||
syncCh chan chan struct{}
|
buildSignals chan buildSignal
|
||||||
|
extender DocumentExtender
|
||||||
|
folderIdLookup folderUIDLookup
|
||||||
|
syncCh chan chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSearchIndex(dashLoader dashboardLoader, evStore eventStore, extender DocumentExtender, folderIDs folderUIDLookup) *searchIndex {
|
func newSearchIndex(dashLoader dashboardLoader, evStore eventStore, extender DocumentExtender, folderIDs folderUIDLookup) *searchIndex {
|
||||||
return &searchIndex{
|
return &searchIndex{
|
||||||
loader: dashLoader,
|
loader: dashLoader,
|
||||||
eventStore: evStore,
|
eventStore: evStore,
|
||||||
perOrgIndex: map[int64]*orgIndex{},
|
perOrgIndex: map[int64]*orgIndex{},
|
||||||
logger: log.New("searchIndex"),
|
initializedOrgs: map[int64]bool{},
|
||||||
buildSignals: make(chan buildSignal),
|
logger: log.New("searchIndex"),
|
||||||
extender: extender,
|
buildSignals: make(chan buildSignal),
|
||||||
folderIdLookup: folderIDs,
|
extender: extender,
|
||||||
syncCh: make(chan chan struct{}),
|
folderIdLookup: folderIDs,
|
||||||
|
syncCh: make(chan chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *searchIndex) isInitialized(_ context.Context, orgId int64) IsSearchReadyResponse {
|
||||||
|
i.initializationMutex.RLock()
|
||||||
|
orgInitialized := i.initializedOrgs[orgId]
|
||||||
|
initialInitComplete := i.initialIndexingComplete
|
||||||
|
i.initializationMutex.RUnlock()
|
||||||
|
|
||||||
|
if orgInitialized && initialInitComplete {
|
||||||
|
return IsSearchReadyResponse{IsReady: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !initialInitComplete {
|
||||||
|
return IsSearchReadyResponse{IsReady: false, Reason: "initial-indexing-ongoing"}
|
||||||
|
}
|
||||||
|
|
||||||
|
i.triggerBuildingOrgIndex(orgId)
|
||||||
|
return IsSearchReadyResponse{IsReady: false, Reason: "org-indexing-ongoing"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *searchIndex) triggerBuildingOrgIndex(orgId int64) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
doneIndexing := make(chan error, 1)
|
||||||
|
signal := buildSignal{orgID: orgId, done: doneIndexing}
|
||||||
|
select {
|
||||||
|
case i.buildSignals <- signal:
|
||||||
|
case <-ctx.Done():
|
||||||
|
i.logger.Warn("Failed to send a build signal to initialize org index", "orgId", orgId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-doneIndexing:
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to build org index", "orgId", orgId, "error", err)
|
||||||
|
} else {
|
||||||
|
i.logger.Debug("Successfully built org index", "orgId", orgId)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
i.logger.Warn("Building org index timeout", "orgId", orgId)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (i *searchIndex) sync(ctx context.Context) error {
|
func (i *searchIndex) sync(ctx context.Context) error {
|
||||||
doneCh := make(chan struct{}, 1)
|
doneCh := make(chan struct{}, 1)
|
||||||
select {
|
select {
|
||||||
@ -149,6 +197,10 @@ func (i *searchIndex) run(ctx context.Context, orgIDs []int64, reIndexSignalCh c
|
|||||||
// Channel to handle signals about asynchronous full re-indexing completion.
|
// Channel to handle signals about asynchronous full re-indexing completion.
|
||||||
reIndexDoneCh := make(chan int64, 1)
|
reIndexDoneCh := make(chan int64, 1)
|
||||||
|
|
||||||
|
i.initializationMutex.Lock()
|
||||||
|
i.initialIndexingComplete = true
|
||||||
|
i.initializationMutex.Unlock()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case doneCh := <-i.syncCh:
|
case doneCh := <-i.syncCh:
|
||||||
@ -421,6 +473,10 @@ func (i *searchIndex) buildOrgIndex(ctx context.Context, orgID int64) (int, erro
|
|||||||
i.perOrgIndex[orgID] = index
|
i.perOrgIndex[orgID] = index
|
||||||
i.mu.Unlock()
|
i.mu.Unlock()
|
||||||
|
|
||||||
|
i.initializationMutex.Lock()
|
||||||
|
i.initializedOrgs[orgID] = true
|
||||||
|
i.initializationMutex.Unlock()
|
||||||
|
|
||||||
if orgID == 1 {
|
if orgID == 1 {
|
||||||
go func() {
|
go func() {
|
||||||
if reader, cancel, err := index.readerForIndex(indexTypeDashboard); err == nil {
|
if reader, cancel, err := index.readerForIndex(indexTypeDashboard); err == nil {
|
||||||
|
@ -45,6 +45,20 @@ func (_m *MockSearchService) IsDisabled() bool {
|
|||||||
return r0
|
return r0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsReady provides a mock function with given fields: ctx, orgId
|
||||||
|
func (_m *MockSearchService) IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse {
|
||||||
|
ret := _m.Called(ctx, orgId)
|
||||||
|
|
||||||
|
var r0 IsSearchReadyResponse
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, int64) IsSearchReadyResponse); ok {
|
||||||
|
r0 = rf(ctx, orgId)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Get(0).(IsSearchReadyResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterDashboardIndexExtender provides a mock function with given fields: ext
|
// RegisterDashboardIndexExtender provides a mock function with given fields: ext
|
||||||
func (_m *MockSearchService) RegisterDashboardIndexExtender(ext DashboardIndexExtender) {
|
func (_m *MockSearchService) RegisterDashboardIndexExtender(ext DashboardIndexExtender) {
|
||||||
_m.Called(ext)
|
_m.Called(ext)
|
||||||
|
@ -64,6 +64,10 @@ type StandardSearchService struct {
|
|||||||
reIndexCh chan struct{}
|
reIndexCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *StandardSearchService) IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse {
|
||||||
|
return s.dashboardIndex.isInitialized(ctx, orgId)
|
||||||
|
}
|
||||||
|
|
||||||
func ProvideService(cfg *setting.Cfg, sql *sqlstore.SQLStore, entityEventStore store.EntityEventsService, ac accesscontrol.Service) SearchService {
|
func ProvideService(cfg *setting.Cfg, sql *sqlstore.SQLStore, entityEventStore store.EntityEventsService, ac accesscontrol.Service) SearchService {
|
||||||
extender := &NoopExtender{}
|
extender := &NoopExtender{}
|
||||||
s := &StandardSearchService{
|
s := &StandardSearchService{
|
||||||
|
@ -10,6 +10,10 @@ import (
|
|||||||
type stubSearchService struct {
|
type stubSearchService struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubSearchService) IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse {
|
||||||
|
return IsSearchReadyResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubSearchService) IsDisabled() bool {
|
func (s *stubSearchService) IsDisabled() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -31,11 +31,17 @@ type DashboardQuery struct {
|
|||||||
From int `json:"from,omitempty"` // for paging
|
From int `json:"from,omitempty"` // for paging
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IsSearchReadyResponse struct {
|
||||||
|
IsReady bool
|
||||||
|
Reason string // initial-indexing-ongoing, org-indexing-ongoing
|
||||||
|
}
|
||||||
|
|
||||||
//go:generate mockery --name SearchService --structname MockSearchService --inpackage --filename search_service_mock.go
|
//go:generate mockery --name SearchService --structname MockSearchService --inpackage --filename search_service_mock.go
|
||||||
type SearchService interface {
|
type SearchService interface {
|
||||||
registry.CanBeDisabled
|
registry.CanBeDisabled
|
||||||
registry.BackgroundService
|
registry.BackgroundService
|
||||||
DoDashboardQuery(ctx context.Context, user *backend.User, orgId int64, query DashboardQuery) *backend.DataResponse
|
DoDashboardQuery(ctx context.Context, user *backend.User, orgId int64, query DashboardQuery) *backend.DataResponse
|
||||||
|
IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse
|
||||||
RegisterDashboardIndexExtender(ext DashboardIndexExtender)
|
RegisterDashboardIndexExtender(ext DashboardIndexExtender)
|
||||||
TriggerReIndex()
|
TriggerReIndex()
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoles(ac accesscontrol.AccessControl) error {
|
func RegisterRoles(service accesscontrol.Service) error {
|
||||||
saReader := accesscontrol.RoleRegistration{
|
saReader := accesscontrol.RoleRegistration{
|
||||||
Role: accesscontrol.RoleDTO{
|
Role: accesscontrol.RoleDTO{
|
||||||
Name: "fixed:serviceaccounts:reader",
|
Name: "fixed:serviceaccounts:reader",
|
||||||
@ -69,7 +69,7 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
|
|||||||
Grants: []string{string(org.RoleAdmin)},
|
Grants: []string{string(org.RoleAdmin)},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ac.DeclareFixedRoles(saReader, saCreator, saWriter); err != nil {
|
if err := service.DeclareFixedRoles(saReader, saCreator, saWriter); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ func ProvideServiceAccountsService(
|
|||||||
usageStats usagestats.Service,
|
usageStats usagestats.Service,
|
||||||
serviceAccountsStore serviceaccounts.Store,
|
serviceAccountsStore serviceaccounts.Store,
|
||||||
permissionService accesscontrol.ServiceAccountPermissionsService,
|
permissionService accesscontrol.ServiceAccountPermissionsService,
|
||||||
|
accesscontrolService accesscontrol.Service,
|
||||||
) (*ServiceAccountsService, error) {
|
) (*ServiceAccountsService, error) {
|
||||||
s := &ServiceAccountsService{
|
s := &ServiceAccountsService{
|
||||||
store: serviceAccountsStore,
|
store: serviceAccountsStore,
|
||||||
@ -38,7 +39,7 @@ func ProvideServiceAccountsService(
|
|||||||
backgroundLog: log.New("serviceaccounts.background"),
|
backgroundLog: log.New("serviceaccounts.background"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := RegisterRoles(ac); err != nil {
|
if err := RegisterRoles(accesscontrolService); err != nil {
|
||||||
s.log.Error("Failed to register roles", "error", err)
|
s.log.Error("Failed to register roles", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
221
pkg/tests/api/prometheus/prometheus_test.go
Normal file
221
pkg/tests/api/prometheus/prometheus_test.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package prometheus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegrationPrometheusBuffered(t *testing.T) {
|
||||||
|
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||||
|
DisableAnonymous: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
grafanaListeningAddr, testEnv := testinfra.StartGrafanaEnv(t, dir, path)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createUser(t, testEnv.SQLStore, user.CreateUserCommand{
|
||||||
|
DefaultOrgRole: string(org.RoleAdmin),
|
||||||
|
Password: "admin",
|
||||||
|
Login: "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
var outgoingRequest *http.Request
|
||||||
|
outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
outgoingRequest = r
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
t.Cleanup(outgoingServer.Close)
|
||||||
|
|
||||||
|
jsonData := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"httpMethod": "post",
|
||||||
|
"httpHeaderName1": "X-CUSTOM-HEADER",
|
||||||
|
"customQueryParameters": "q1=1&q2=2",
|
||||||
|
})
|
||||||
|
secureJSONData := map[string]string{
|
||||||
|
"basicAuthPassword": "basicAuthPassword",
|
||||||
|
"httpHeaderValue1": "custom-header-value",
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := "prometheus"
|
||||||
|
err := testEnv.Server.HTTPServer.DataSourcesService.AddDataSource(ctx, &datasources.AddDataSourceCommand{
|
||||||
|
OrgId: 1,
|
||||||
|
Access: datasources.DS_ACCESS_PROXY,
|
||||||
|
Name: "Prometheus",
|
||||||
|
Type: datasources.DS_PROMETHEUS,
|
||||||
|
Uid: uid,
|
||||||
|
Url: outgoingServer.URL,
|
||||||
|
BasicAuth: true,
|
||||||
|
BasicAuthUser: "basicAuthUser",
|
||||||
|
JsonData: jsonData,
|
||||||
|
SecureJsonData: secureJSONData,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("When calling /api/ds/query should set expected headers on outgoing HTTP request", func(t *testing.T) {
|
||||||
|
query := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"datasource": map[string]interface{}{
|
||||||
|
"uid": uid,
|
||||||
|
},
|
||||||
|
"expr": "up",
|
||||||
|
"instantQuery": true,
|
||||||
|
})
|
||||||
|
buf1 := &bytes.Buffer{}
|
||||||
|
err = json.NewEncoder(buf1).Encode(dtos.MetricRequest{
|
||||||
|
From: "now-1h",
|
||||||
|
To: "now",
|
||||||
|
Queries: []*simplejson.Json{query},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
u := fmt.Sprintf("http://admin:admin@%s/api/ds/query", grafanaListeningAddr)
|
||||||
|
// nolint:gosec
|
||||||
|
resp, err := http.Post(u, "application/json", buf1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, outgoingRequest)
|
||||||
|
require.Equal(t, "/api/v1/query_range?q1=1&q2=2", outgoingRequest.URL.String())
|
||||||
|
require.Equal(t, "custom-header-value", outgoingRequest.Header.Get("X-CUSTOM-HEADER"))
|
||||||
|
username, pwd, ok := outgoingRequest.BasicAuth()
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "basicAuthUser", username)
|
||||||
|
require.Equal(t, "basicAuthPassword", pwd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPrometheusClient(t *testing.T) {
|
||||||
|
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||||
|
EnableFeatureToggles: []string{"prometheusStreamingJSONParser"},
|
||||||
|
})
|
||||||
|
|
||||||
|
grafanaListeningAddr, testEnv := testinfra.StartGrafanaEnv(t, dir, path)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createUser(t, testEnv.SQLStore, user.CreateUserCommand{
|
||||||
|
DefaultOrgRole: string(org.RoleAdmin),
|
||||||
|
Password: "admin",
|
||||||
|
Login: "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
var outgoingRequest *http.Request
|
||||||
|
outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
outgoingRequest = r
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
t.Cleanup(outgoingServer.Close)
|
||||||
|
|
||||||
|
jsonData := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"httpMethod": "post",
|
||||||
|
"httpHeaderName1": "X-CUSTOM-HEADER",
|
||||||
|
"customQueryParameters": "q1=1&q2=2",
|
||||||
|
})
|
||||||
|
secureJSONData := map[string]string{
|
||||||
|
"basicAuthPassword": "basicAuthPassword",
|
||||||
|
"httpHeaderValue1": "custom-header-value",
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := "prometheus"
|
||||||
|
err := testEnv.Server.HTTPServer.DataSourcesService.AddDataSource(ctx, &datasources.AddDataSourceCommand{
|
||||||
|
OrgId: 1,
|
||||||
|
Access: datasources.DS_ACCESS_PROXY,
|
||||||
|
Name: "Prometheus",
|
||||||
|
Type: datasources.DS_PROMETHEUS,
|
||||||
|
Uid: uid,
|
||||||
|
Url: outgoingServer.URL,
|
||||||
|
BasicAuth: true,
|
||||||
|
BasicAuthUser: "basicAuthUser",
|
||||||
|
JsonData: jsonData,
|
||||||
|
SecureJsonData: secureJSONData,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("When calling /api/ds/query should set expected headers on outgoing HTTP request", func(t *testing.T) {
|
||||||
|
query := simplejson.NewFromAny(map[string]interface{}{
|
||||||
|
"datasource": map[string]interface{}{
|
||||||
|
"uid": uid,
|
||||||
|
},
|
||||||
|
"expr": "up",
|
||||||
|
"instantQuery": true,
|
||||||
|
})
|
||||||
|
buf1 := &bytes.Buffer{}
|
||||||
|
err = json.NewEncoder(buf1).Encode(dtos.MetricRequest{
|
||||||
|
From: "now-1h",
|
||||||
|
To: "now",
|
||||||
|
Queries: []*simplejson.Json{query},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
u := fmt.Sprintf("http://admin:admin@%s/api/ds/query", grafanaListeningAddr)
|
||||||
|
// nolint:gosec
|
||||||
|
resp, err := http.Post(u, "application/json", buf1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, outgoingRequest)
|
||||||
|
require.Equal(t, "/api/v1/query_range", outgoingRequest.URL.Path)
|
||||||
|
require.Contains(t, outgoingRequest.URL.String(), "&q1=1&q2=2")
|
||||||
|
require.Equal(t, "custom-header-value", outgoingRequest.Header.Get("X-CUSTOM-HEADER"))
|
||||||
|
username, pwd, ok := outgoingRequest.BasicAuth()
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "basicAuthUser", username)
|
||||||
|
require.Equal(t, "basicAuthPassword", pwd)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("When calling /api/datasources/uid/{uid}/resources/api/v1/labels should set expected headers on outgoing HTTP request", func(t *testing.T) {
|
||||||
|
u := fmt.Sprintf("http://%s/api/datasources/uid/%s/resources/api/v1/labels", grafanaListeningAddr, uid)
|
||||||
|
// nolint:gosec
|
||||||
|
resp, err := http.Post(u, "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
_, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NotNil(t, outgoingRequest)
|
||||||
|
require.Equal(t, "/api/v1/labels?q1=1&q2=2", outgoingRequest.URL.String())
|
||||||
|
require.Equal(t, "custom-header-value", outgoingRequest.Header.Get("X-CUSTOM-HEADER"))
|
||||||
|
username, pwd, ok := outgoingRequest.BasicAuth()
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "basicAuthUser", username)
|
||||||
|
require.Equal(t, "basicAuthPassword", pwd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUser(t *testing.T, store *sqlstore.SQLStore, cmd user.CreateUserCommand) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
store.Cfg.AutoAssignOrg = true
|
||||||
|
store.Cfg.AutoAssignOrgId = 1
|
||||||
|
|
||||||
|
u, err := store.CreateUser(context.Background(), cmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return u.ID
|
||||||
|
}
|
@ -244,7 +244,14 @@ func addDateHistogramAgg(aggBuilder es.AggBuilder, bucketAgg *BucketAgg, timeFro
|
|||||||
a.Format = bucketAgg.Settings.Get("format").MustString(es.DateFormatEpochMS)
|
a.Format = bucketAgg.Settings.Get("format").MustString(es.DateFormatEpochMS)
|
||||||
|
|
||||||
if a.FixedInterval == "auto" {
|
if a.FixedInterval == "auto" {
|
||||||
a.FixedInterval = "$__interval"
|
// note this is not really a valid grafana-variable-handling,
|
||||||
|
// because normally this would not match `$__interval_ms`,
|
||||||
|
// but because how we apply these in the go-code, this will work
|
||||||
|
// correctly, and becomes something like `500ms`.
|
||||||
|
// a nicer way would be to use `${__interval_ms}ms`, but
|
||||||
|
// that format is not recognized where we apply these variables
|
||||||
|
// in the elasticsearch datasource
|
||||||
|
a.FixedInterval = "$__interval_msms"
|
||||||
}
|
}
|
||||||
|
|
||||||
if offset, err := bucketAgg.Settings.Get("offset").String(); err == nil {
|
if offset, err := bucketAgg.Settings.Get("offset").String(); err == nil {
|
||||||
|
@ -325,7 +325,7 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
|
|||||||
require.Equal(t, firstLevel.Aggregation.Type, "date_histogram")
|
require.Equal(t, firstLevel.Aggregation.Type, "date_histogram")
|
||||||
hAgg := firstLevel.Aggregation.Aggregation.(*es.DateHistogramAgg)
|
hAgg := firstLevel.Aggregation.Aggregation.(*es.DateHistogramAgg)
|
||||||
require.Equal(t, hAgg.Field, "@timestamp")
|
require.Equal(t, hAgg.Field, "@timestamp")
|
||||||
require.Equal(t, hAgg.FixedInterval, "$__interval")
|
require.Equal(t, hAgg.FixedInterval, "$__interval_msms")
|
||||||
require.Equal(t, hAgg.MinDocCount, 2)
|
require.Equal(t, hAgg.MinDocCount, 2)
|
||||||
|
|
||||||
t.Run("Should not include time_zone when timeZone is utc", func(t *testing.T) {
|
t.Run("Should not include time_zone when timeZone is utc", func(t *testing.T) {
|
||||||
|
@ -9,13 +9,15 @@ import (
|
|||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
"github.com/grafana/grafana/pkg/services/searchV2"
|
"github.com/grafana/grafana/pkg/services/searchV2"
|
||||||
"github.com/grafana/grafana/pkg/services/store"
|
"github.com/grafana/grafana/pkg/services/store"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/tsdb/testdatasource"
|
"github.com/grafana/grafana/pkg/tsdb/testdatasource"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatasourceName is the string constant used as the datasource name in requests
|
// DatasourceName is the string constant used as the datasource name in requests
|
||||||
@ -34,8 +36,19 @@ const DatasourceUID = "grafana"
|
|||||||
// This is important to do since otherwise we will only get a
|
// This is important to do since otherwise we will only get a
|
||||||
// not implemented error response from plugin at runtime.
|
// not implemented error response from plugin at runtime.
|
||||||
var (
|
var (
|
||||||
_ backend.QueryDataHandler = (*Service)(nil)
|
_ backend.QueryDataHandler = (*Service)(nil)
|
||||||
_ backend.CheckHealthHandler = (*Service)(nil)
|
_ backend.CheckHealthHandler = (*Service)(nil)
|
||||||
|
namespace = "grafana"
|
||||||
|
subsystem = "grafanads"
|
||||||
|
dashboardSearchNotServedRequestsCounter = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Subsystem: subsystem,
|
||||||
|
Name: "dashboard_search_requests_not_served_total",
|
||||||
|
Help: "A counter for dashboard search requests that could not be served due to an ongoing search engine indexing",
|
||||||
|
},
|
||||||
|
[]string{"reason"},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProvideService(cfg *setting.Cfg, search searchV2.SearchService, store store.StorageService) *Service {
|
func ProvideService(cfg *setting.Cfg, search searchV2.SearchService, store store.StorageService) *Service {
|
||||||
@ -46,6 +59,7 @@ func newService(cfg *setting.Cfg, search searchV2.SearchService, store store.Sto
|
|||||||
s := &Service{
|
s := &Service{
|
||||||
search: search,
|
search: search,
|
||||||
store: store,
|
store: store,
|
||||||
|
log: log.New("grafanads"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
@ -55,6 +69,7 @@ func newService(cfg *setting.Cfg, search searchV2.SearchService, store store.Sto
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
search searchV2.SearchService
|
search searchV2.SearchService
|
||||||
store store.StorageService
|
store store.StorageService
|
||||||
|
log log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func DataSourceModel(orgId int64) *datasources.DataSource {
|
func DataSourceModel(orgId int64) *datasources.DataSource {
|
||||||
@ -157,6 +172,21 @@ func (s *Service) doRandomWalk(query backend.DataQuery) backend.DataResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) doSearchQuery(ctx context.Context, req *backend.QueryDataRequest, query backend.DataQuery) backend.DataResponse {
|
func (s *Service) doSearchQuery(ctx context.Context, req *backend.QueryDataRequest, query backend.DataQuery) backend.DataResponse {
|
||||||
|
searchReadinessCheckResp := s.search.IsReady(ctx, req.PluginContext.OrgID)
|
||||||
|
if !searchReadinessCheckResp.IsReady {
|
||||||
|
dashboardSearchNotServedRequestsCounter.With(prometheus.Labels{
|
||||||
|
"reason": searchReadinessCheckResp.Reason,
|
||||||
|
}).Inc()
|
||||||
|
|
||||||
|
return backend.DataResponse{
|
||||||
|
Frames: data.Frames{
|
||||||
|
&data.Frame{
|
||||||
|
Name: "Loading",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m := requestModel{}
|
m := requestModel{}
|
||||||
err := json.Unmarshal(query.JSON, &m)
|
err := json.Unmarshal(query.JSON, &m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -3,12 +3,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/adapters"
|
"github.com/grafana/grafana/pkg/plugins/adapters"
|
||||||
"github.com/grafana/grafana/pkg/services/datasources"
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
@ -16,11 +14,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
headerName = "httpHeaderName"
|
|
||||||
headerValue = "httpHeaderValue"
|
|
||||||
)
|
|
||||||
|
|
||||||
var oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool {
|
var oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool {
|
||||||
return oAuthTokenService.IsOAuthPassThruEnabled(ds)
|
return oAuthTokenService.IsOAuthPassThruEnabled(ds)
|
||||||
}
|
}
|
||||||
@ -126,11 +119,6 @@ func generateRequest(ctx context.Context, ds *datasources.DataSource, decryptedJ
|
|||||||
Headers: query.Headers,
|
Headers: query.Headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Configured Custom Headers to query request.
|
|
||||||
for k, v := range customHeaders(ds.JsonData, instanceSettings.DecryptedSecureJSONData) {
|
|
||||||
req.Headers[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, q := range query.Queries {
|
for _, q := range query.Queries {
|
||||||
modelJSON, err := q.Model.MarshalJSON()
|
modelJSON, err := q.Model.MarshalJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -151,24 +139,4 @@ func generateRequest(ctx context.Context, ds *datasources.DataSource, decryptedJ
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func customHeaders(jsonData *simplejson.Json, decryptedJsonData map[string]string) map[string]string {
|
|
||||||
if jsonData == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data := jsonData.MustMap()
|
|
||||||
|
|
||||||
headers := map[string]string{}
|
|
||||||
for k := range data {
|
|
||||||
if strings.HasPrefix(k, headerName) {
|
|
||||||
if header, ok := data[k].(string); ok {
|
|
||||||
valueKey := strings.ReplaceAll(k, headerName, headerValue)
|
|
||||||
headers[header] = decryptedJsonData[valueKey]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ legacydata.RequestHandler = &Service{}
|
var _ legacydata.RequestHandler = &Service{}
|
||||||
|
@ -64,39 +64,6 @@ func TestHandleRequest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_generateRequest(t *testing.T) {
|
|
||||||
t.Run("Should attach custom headers to request if present", func(t *testing.T) {
|
|
||||||
jsonData := simplejson.New()
|
|
||||||
jsonData.Set(headerName+"testOne", "x-test-one")
|
|
||||||
jsonData.Set("testOne", "x-test-wrong")
|
|
||||||
jsonData.Set(headerName+"testTwo", "x-test-two")
|
|
||||||
|
|
||||||
decryptedJsonData := map[string]string{
|
|
||||||
headerValue + "testOne": "secret-value-one",
|
|
||||||
headerValue + "testTwo": "secret-value-two",
|
|
||||||
"something": "else",
|
|
||||||
}
|
|
||||||
|
|
||||||
ds := &datasources.DataSource{Id: 12, Type: "unregisteredType", JsonData: jsonData}
|
|
||||||
query := legacydata.DataQuery{
|
|
||||||
TimeRange: &legacydata.DataTimeRange{},
|
|
||||||
Queries: []legacydata.DataSubQuery{
|
|
||||||
{RefID: "A", DataSource: &datasources.DataSource{Id: 1, Type: "test"}, Model: simplejson.New()},
|
|
||||||
{RefID: "B", DataSource: &datasources.DataSource{Id: 1, Type: "test"}, Model: simplejson.New()},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := generateRequest(context.Background(), ds, decryptedJsonData, query)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, req)
|
|
||||||
require.EqualValues(t,
|
|
||||||
map[string]string{
|
|
||||||
"x-test-one": "secret-value-one",
|
|
||||||
"x-test-two": "secret-value-two",
|
|
||||||
}, req.Headers)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakePluginsClient struct {
|
type fakePluginsClient struct {
|
||||||
plugins.Client
|
plugins.Client
|
||||||
backend.QueryDataHandlerFunc
|
backend.QueryDataHandlerFunc
|
||||||
|
@ -20,6 +20,7 @@ describe('InputDatasource', () => {
|
|||||||
name: 'xxx',
|
name: 'xxx',
|
||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: false,
|
||||||
jsonData: {
|
jsonData: {
|
||||||
data,
|
data,
|
||||||
},
|
},
|
||||||
|
@ -5,6 +5,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { AsyncMultiSelect, Icon, Button, useStyles2 } from '@grafana/ui';
|
import { AsyncMultiSelect, Icon, Button, useStyles2 } from '@grafana/ui';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { DashboardSearchHit } from 'app/features/search/types';
|
||||||
import { FolderInfo, PermissionLevelString } from 'app/types';
|
import { FolderInfo, PermissionLevelString } from 'app/types';
|
||||||
|
|
||||||
export interface FolderFilterProps {
|
export interface FolderFilterProps {
|
||||||
@ -75,7 +76,9 @@ async function getFoldersAsOptions(searchString: string, setLoading: (loading: b
|
|||||||
permission: PermissionLevelString.View,
|
permission: PermissionLevelString.View,
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchHits = await getBackendSrv().search(params);
|
// FIXME: stop using id from search and use UID instead
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[];
|
||||||
const options = searchHits.map((d) => ({ label: d.title, value: { id: d.id, title: d.title } }));
|
const options = searchHits.map((d) => ({ label: d.title, value: { id: d.id, title: d.title } }));
|
||||||
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
|
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
|
||||||
options.unshift({ label: 'General', value: { id: 0, title: 'General' } });
|
options.unshift({ label: 'General', value: { id: 0, title: 'General' } });
|
||||||
|
@ -43,6 +43,7 @@ const TRANSLATED_MENU_ITEMS: Record<string, MessageDescriptor> = {
|
|||||||
|
|
||||||
cfg: defineMessage({ id: 'nav.config', message: 'Configuration' }),
|
cfg: defineMessage({ id: 'nav.config', message: 'Configuration' }),
|
||||||
datasources: defineMessage({ id: 'nav.datasources', message: 'Data sources' }),
|
datasources: defineMessage({ id: 'nav.datasources', message: 'Data sources' }),
|
||||||
|
correlations: defineMessage({ id: 'nav.correlations', message: 'Correlations' }),
|
||||||
users: defineMessage({ id: 'nav.users', message: 'Users' }),
|
users: defineMessage({ id: 'nav.users', message: 'Users' }),
|
||||||
teams: defineMessage({ id: 'nav.teams', message: 'Teams' }),
|
teams: defineMessage({ id: 'nav.teams', message: 'Teams' }),
|
||||||
plugins: defineMessage({ id: 'nav.plugins', message: 'Plugins' }),
|
plugins: defineMessage({ id: 'nav.plugins', message: 'Plugins' }),
|
||||||
|
@ -4,6 +4,7 @@ import React, { FC } from 'react';
|
|||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { AsyncSelect } from '@grafana/ui';
|
import { AsyncSelect } from '@grafana/ui';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { DashboardSearchHit } from 'app/features/search/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated prefer using dashboard uid rather than id
|
* @deprecated prefer using dashboard uid rather than id
|
||||||
@ -70,10 +71,12 @@ async function getDashboards(
|
|||||||
label: string,
|
label: string,
|
||||||
excludedDashboards?: string[]
|
excludedDashboards?: string[]
|
||||||
): Promise<Array<SelectableValue<DashboardPickerItem>>> {
|
): Promise<Array<SelectableValue<DashboardPickerItem>>> {
|
||||||
const result = await backendSrv.search({ type: 'dash-db', query, limit: 100 });
|
// FIXME: stop using id from search and use UID instead
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const result = (await backendSrv.search({ type: 'dash-db', query, limit: 100 })) as DashboardSearchHit[];
|
||||||
const dashboards = result.map(({ id, uid = '', title, folderTitle }) => {
|
const dashboards = result.map(({ id, uid = '', title, folderTitle }) => {
|
||||||
const value: DashboardPickerItem = {
|
const value: DashboardPickerItem = {
|
||||||
id,
|
id: id!,
|
||||||
uid,
|
uid,
|
||||||
[label]: `${folderTitle ?? 'General'}/${title}`,
|
[label]: `${folderTitle ?? 'General'}/${title}`,
|
||||||
};
|
};
|
||||||
|
@ -74,9 +74,12 @@ export const Page: PageType = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OldNavOnly = () => null;
|
||||||
|
OldNavOnly.displayName = 'OldNavOnly';
|
||||||
|
|
||||||
Page.Header = PageHeader;
|
Page.Header = PageHeader;
|
||||||
Page.Contents = PageContents;
|
Page.Contents = PageContents;
|
||||||
Page.OldNavOnly = () => null;
|
Page.OldNavOnly = OldNavOnly;
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
const shadow = theme.isDark
|
const shadow = theme.isDark
|
||||||
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardSearchItem } from 'app/features/search/types';
|
||||||
import { DashboardDTO } from 'app/types';
|
import { DashboardDTO } from 'app/types';
|
||||||
|
|
||||||
interface Props extends Omit<AsyncSelectProps<DashboardPickerDTO>, 'value' | 'onChange' | 'loadOptions' | ''> {
|
interface Props extends Omit<AsyncSelectProps<DashboardPickerDTO>, 'value' | 'onChange' | 'loadOptions' | ''> {
|
||||||
@ -18,8 +18,8 @@ export type DashboardPickerDTO = Pick<DashboardDTO['dashboard'], 'uid' | 'title'
|
|||||||
const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
|
const formatLabel = (folderTitle = 'General', dashboardTitle: string) => `${folderTitle}/${dashboardTitle}`;
|
||||||
|
|
||||||
const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<DashboardPickerDTO>>> => {
|
const getDashboards = debounce((query = ''): Promise<Array<SelectableValue<DashboardPickerDTO>>> => {
|
||||||
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
|
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchItem[]) => {
|
||||||
return result.map((item: DashboardSearchHit) => ({
|
return result.map((item: DashboardSearchItem) => ({
|
||||||
value: {
|
value: {
|
||||||
// dashboards uid here is always defined as this endpoint does not return the default home dashboard
|
// dashboards uid here is always defined as this endpoint does not return the default home dashboard
|
||||||
uid: item.uid!,
|
uid: item.uid!,
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput';
|
import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput';
|
||||||
import * as api from '../../../../features/manage-dashboards/state/actions';
|
import * as api from '../../../../features/manage-dashboards/state/actions';
|
||||||
import { DashboardSearchHit } from '../../../../features/search/types';
|
import { DashboardSearchItem } from '../../../../features/search/types';
|
||||||
import { PermissionLevelString } from '../../../../types';
|
import { PermissionLevelString } from '../../../../types';
|
||||||
|
|
||||||
import { ALL_FOLDER, GENERAL_FOLDER } from './ReadonlyFolderPicker';
|
import { ALL_FOLDER, GENERAL_FOLDER } from './ReadonlyFolderPicker';
|
||||||
import { getFolderAsOption, getFoldersAsOptions } from './api';
|
import { getFolderAsOption, getFoldersAsOptions } from './api';
|
||||||
|
|
||||||
function getTestContext(
|
function getTestContext(
|
||||||
searchHits: DashboardSearchHit[] = [],
|
searchHits: DashboardSearchItem[] = [],
|
||||||
folderById: { id: number; title: string } = { id: 1, title: 'Folder 1' }
|
folderById: { id: number; title: string } = { id: 1, title: 'Folder 1' }
|
||||||
) {
|
) {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
@ -32,8 +32,10 @@ export interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DefaultDashboardSearchItem = Omit<DashboardSearchItem, 'uid'> & { uid?: string };
|
||||||
|
|
||||||
export type State = UserPreferencesDTO & {
|
export type State = UserPreferencesDTO & {
|
||||||
dashboards: DashboardSearchItem[];
|
dashboards: DashboardSearchItem[] | DefaultDashboardSearchItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const themes: SelectableValue[] = [
|
const themes: SelectableValue[] = [
|
||||||
@ -75,14 +77,13 @@ const languages: Array<SelectableValue<string>> = [
|
|||||||
|
|
||||||
const i18nFlag = Boolean(config.featureToggles.internationalization);
|
const i18nFlag = Boolean(config.featureToggles.internationalization);
|
||||||
|
|
||||||
const DEFAULT_DASHBOARD_HOME: DashboardSearchItem = {
|
const DEFAULT_DASHBOARD_HOME: DefaultDashboardSearchItem = {
|
||||||
title: 'Default',
|
title: 'Default',
|
||||||
tags: [],
|
tags: [],
|
||||||
type: '' as DashboardSearchItemType,
|
type: '' as DashboardSearchItemType,
|
||||||
uid: undefined,
|
uid: undefined,
|
||||||
uri: '',
|
uri: '',
|
||||||
url: '',
|
url: '',
|
||||||
folderId: 0,
|
|
||||||
folderTitle: '',
|
folderTitle: '',
|
||||||
folderUid: '',
|
folderUid: '',
|
||||||
folderUrl: '',
|
folderUrl: '',
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
type LocaleIdentifier = `${string}-${string}`;
|
export const ENGLISH_US = 'en-US';
|
||||||
|
export const FRENCH_FRANCE = 'fr-FR';
|
||||||
|
export const SPANISH_SPAIN = 'es-ES';
|
||||||
|
|
||||||
export const ENGLISH_US: LocaleIdentifier = 'en-US';
|
export const DEFAULT_LOCALE = ENGLISH_US;
|
||||||
export const FRENCH_FRANCE: LocaleIdentifier = 'fr-FR';
|
|
||||||
export const SPANISH_SPAIN: LocaleIdentifier = 'es-ES';
|
|
||||||
|
|
||||||
export const DEFAULT_LOCALE: LocaleIdentifier = ENGLISH_US;
|
export const VALID_LOCALES: string[] = [ENGLISH_US, FRENCH_FRANCE, SPANISH_SPAIN];
|
||||||
|
|
||||||
export const VALID_LOCALES: LocaleIdentifier[] = [ENGLISH_US, FRENCH_FRANCE, SPANISH_SPAIN];
|
|
||||||
|
@ -6,17 +6,17 @@ import config from 'app/core/config';
|
|||||||
|
|
||||||
import { messages as fallbackMessages } from '../../../locales/en-US/messages';
|
import { messages as fallbackMessages } from '../../../locales/en-US/messages';
|
||||||
|
|
||||||
import { DEFAULT_LOCALE, FRENCH_FRANCE, SPANISH_SPAIN, VALID_LOCALES } from './constants';
|
import { DEFAULT_LOCALE, VALID_LOCALES } from './constants';
|
||||||
|
|
||||||
let i18nInstance: I18n;
|
let i18nInstance: I18n;
|
||||||
|
|
||||||
export async function getI18n(localInput = DEFAULT_LOCALE) {
|
export async function initI18n(localInput: string = DEFAULT_LOCALE) {
|
||||||
if (i18nInstance) {
|
const validatedLocale = VALID_LOCALES.includes(localInput) ? localInput : DEFAULT_LOCALE;
|
||||||
|
|
||||||
|
if (i18nInstance && i18nInstance.locale === validatedLocale) {
|
||||||
return i18nInstance;
|
return i18nInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedLocale = VALID_LOCALES.includes(localInput) ? localInput : DEFAULT_LOCALE;
|
|
||||||
|
|
||||||
// Dynamically load the messages for the user's locale
|
// Dynamically load the messages for the user's locale
|
||||||
const imp =
|
const imp =
|
||||||
config.featureToggles.internationalization &&
|
config.featureToggles.internationalization &&
|
||||||
@ -53,23 +53,9 @@ interface I18nProviderProps {
|
|||||||
}
|
}
|
||||||
export function I18nProvider({ children }: I18nProviderProps) {
|
export function I18nProvider({ children }: I18nProviderProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let loc;
|
const locale = config.featureToggles.internationalization ? config.bootData.user.locale : DEFAULT_LOCALE;
|
||||||
if (config.featureToggles.internationalization) {
|
|
||||||
// TODO: Use locale preference instead of weekStart
|
|
||||||
switch (config.bootData.user.weekStart) {
|
|
||||||
case 'saturday':
|
|
||||||
loc = SPANISH_SPAIN;
|
|
||||||
break;
|
|
||||||
case 'sunday':
|
|
||||||
loc = FRENCH_FRANCE;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
loc = DEFAULT_LOCALE;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getI18n(loc);
|
initI18n(locale);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -44,6 +44,7 @@ describe('navModelReducer', () => {
|
|||||||
it('then state should be correct', () => {
|
it('then state should be correct', () => {
|
||||||
const originalCfg = { id: 'cfg', subTitle: 'Organization: Org 1', text: 'Configuration' };
|
const originalCfg = { id: 'cfg', subTitle: 'Organization: Org 1', text: 'Configuration' };
|
||||||
const datasources = { id: 'datasources', text: 'Data Sources' };
|
const datasources = { id: 'datasources', text: 'Data Sources' };
|
||||||
|
const correlations = { id: 'correlations', text: 'Correlations' };
|
||||||
const users = { id: 'users', text: 'Users' };
|
const users = { id: 'users', text: 'Users' };
|
||||||
const teams = { id: 'teams', text: 'Teams' };
|
const teams = { id: 'teams', text: 'Teams' };
|
||||||
const plugins = { id: 'plugins', text: 'Plugins' };
|
const plugins = { id: 'plugins', text: 'Plugins' };
|
||||||
@ -53,6 +54,7 @@ describe('navModelReducer', () => {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
cfg: { ...originalCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
cfg: { ...originalCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
||||||
datasources: { ...datasources, parentItem: originalCfg },
|
datasources: { ...datasources, parentItem: originalCfg },
|
||||||
|
correlations: { ...correlations, parentItem: originalCfg },
|
||||||
users: { ...users, parentItem: originalCfg },
|
users: { ...users, parentItem: originalCfg },
|
||||||
teams: { ...teams, parentItem: originalCfg },
|
teams: { ...teams, parentItem: originalCfg },
|
||||||
plugins: { ...plugins, parentItem: originalCfg },
|
plugins: { ...plugins, parentItem: originalCfg },
|
||||||
@ -66,6 +68,7 @@ describe('navModelReducer', () => {
|
|||||||
const expectedState = {
|
const expectedState = {
|
||||||
cfg: { ...newCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
cfg: { ...newCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
||||||
datasources: { ...datasources, parentItem: newCfg },
|
datasources: { ...datasources, parentItem: newCfg },
|
||||||
|
correlations: { ...correlations, parentItem: newCfg },
|
||||||
users: { ...users, parentItem: newCfg },
|
users: { ...users, parentItem: newCfg },
|
||||||
teams: { ...teams, parentItem: newCfg },
|
teams: { ...teams, parentItem: newCfg },
|
||||||
plugins: { ...plugins, parentItem: newCfg },
|
plugins: { ...plugins, parentItem: newCfg },
|
||||||
|
@ -79,6 +79,7 @@ export const navIndexReducer = (state: NavIndex = initialState, action: AnyActio
|
|||||||
...state,
|
...state,
|
||||||
cfg: { ...state.cfg, subTitle },
|
cfg: { ...state.cfg, subTitle },
|
||||||
datasources: getItemWithNewSubTitle(state.datasources, subTitle),
|
datasources: getItemWithNewSubTitle(state.datasources, subTitle),
|
||||||
|
correlations: getItemWithNewSubTitle(state.correlations, subTitle),
|
||||||
users: getItemWithNewSubTitle(state.users, subTitle),
|
users: getItemWithNewSubTitle(state.users, subTitle),
|
||||||
teams: getItemWithNewSubTitle(state.teams, subTitle),
|
teams: getItemWithNewSubTitle(state.teams, subTitle),
|
||||||
plugins: getItemWithNewSubTitle(state.plugins, subTitle),
|
plugins: getItemWithNewSubTitle(state.plugins, subTitle),
|
||||||
|
@ -18,7 +18,7 @@ import { BackendSrv as BackendService, BackendSrvRequest, config, FetchError, Fe
|
|||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { loadUrlToken } from 'app/core/utils/urlToken';
|
import { loadUrlToken } from 'app/core/utils/urlToken';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardSearchItem } from 'app/features/search/types';
|
||||||
import { getGrafanaStorage } from 'app/features/storage/storage';
|
import { getGrafanaStorage } from 'app/features/storage/storage';
|
||||||
import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
|
import { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
|
||||||
import { DashboardDTO, FolderDTO } from 'app/types';
|
import { DashboardDTO, FolderDTO } from 'app/types';
|
||||||
@ -439,7 +439,7 @@ export class BackendSrv implements BackendService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
search(query: any): Promise<DashboardSearchHit[]> {
|
search(query: any): Promise<DashboardSearchItem[]> {
|
||||||
return this.get('/api/search', query);
|
return this.get('/api/search', query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,7 +113,8 @@ export class SearchSrv {
|
|||||||
// create folder index
|
// create folder index
|
||||||
for (const hit of results) {
|
for (const hit of results) {
|
||||||
if (hit.type === 'dash-folder') {
|
if (hit.type === 'dash-folder') {
|
||||||
sections[hit.id] = {
|
// FIXME: Use hit.uid instead
|
||||||
|
sections[hit.id!] = {
|
||||||
id: hit.id,
|
id: hit.id,
|
||||||
uid: hit.uid,
|
uid: hit.uid,
|
||||||
title: hit.title,
|
title: hit.title,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import impressionSrv from 'app/core/services/impression_srv';
|
import impressionSrv from 'app/core/services/impression_srv';
|
||||||
import { SearchSrv } from 'app/core/services/search_srv';
|
import { SearchSrv } from 'app/core/services/search_srv';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardSearchItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import { backendSrv } from '../services/backend_srv';
|
import { backendSrv } from '../services/backend_srv';
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ describe('SearchSrv', () => {
|
|||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ uid: 'DSNdW0gVk', title: 'second but first' },
|
{ uid: 'DSNdW0gVk', title: 'second but first' },
|
||||||
{ uid: 'srx16xR4z', title: 'first but second' },
|
{ uid: 'srx16xR4z', title: 'first but second' },
|
||||||
] as DashboardSearchHit[]);
|
] as DashboardSearchItem[]);
|
||||||
}
|
}
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
});
|
});
|
||||||
@ -70,7 +70,7 @@ describe('SearchSrv', () => {
|
|||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ uid: 'DSNdW0gVk', title: 'two' },
|
{ uid: 'DSNdW0gVk', title: 'two' },
|
||||||
{ uid: 'srx16xR4z', title: 'one' },
|
{ uid: 'srx16xR4z', title: 'one' },
|
||||||
] as DashboardSearchHit[]);
|
] as DashboardSearchItem[]);
|
||||||
}
|
}
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
});
|
});
|
||||||
@ -98,7 +98,7 @@ describe('SearchSrv', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
searchMock.mockImplementation((options) => {
|
searchMock.mockImplementation((options) => {
|
||||||
if (options.starred) {
|
if (options.starred) {
|
||||||
return Promise.resolve([{ id: 1, title: 'starred' }] as DashboardSearchHit[]);
|
return Promise.resolve([{ uid: '1', title: 'starred' }] as DashboardSearchItem[]);
|
||||||
}
|
}
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
});
|
});
|
||||||
@ -123,9 +123,9 @@ describe('SearchSrv', () => {
|
|||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ uid: 'srx16xR4z', title: 'starred and recent', isStarred: true },
|
{ uid: 'srx16xR4z', title: 'starred and recent', isStarred: true },
|
||||||
{ uid: 'DSNdW0gVk', title: 'recent' },
|
{ uid: 'DSNdW0gVk', title: 'recent' },
|
||||||
] as DashboardSearchHit[]);
|
] as DashboardSearchItem[]);
|
||||||
}
|
}
|
||||||
return Promise.resolve([{ uid: 'srx16xR4z', title: 'starred and recent' }] as DashboardSearchHit[]);
|
return Promise.resolve([{ uid: 'srx16xR4z', title: 'starred and recent' }] as DashboardSearchItem[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
impressionSrv.getDashboardOpened = jest.fn().mockResolvedValue(['srx16xR4z', 'DSNdW0gVk']);
|
impressionSrv.getDashboardOpened = jest.fn().mockResolvedValue(['srx16xR4z', 'DSNdW0gVk']);
|
||||||
|
3
public/app/core/utils/types.ts
Normal file
3
public/app/core/utils/types.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T;
|
||||||
|
|
||||||
|
export const isTruthy = <T>(value: T): value is Truthy<T> => Boolean(value);
|
@ -41,6 +41,7 @@ const mockRuleSourceByName = () => {
|
|||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
jsonData: {} as DataSourceJsonData,
|
jsonData: {} as DataSourceJsonData,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,6 +100,7 @@ const mockedRules: CombinedRule[] = [
|
|||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
jsonData: {} as DataSourceJsonData,
|
jsonData: {} as DataSourceJsonData,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -128,6 +130,7 @@ const mockedRules: CombinedRule[] = [
|
|||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
jsonData: {} as DataSourceJsonData,
|
jsonData: {} as DataSourceJsonData,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -136,6 +136,7 @@ const mockCloudRule = {
|
|||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
jsonData: {} as DataSourceJsonData,
|
jsonData: {} as DataSourceJsonData,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -59,6 +59,7 @@ export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData
|
|||||||
},
|
},
|
||||||
...meta,
|
...meta,
|
||||||
} as any as DataSourcePluginMeta,
|
} as any as DataSourcePluginMeta,
|
||||||
|
readOnly: false,
|
||||||
...partial,
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,7 @@ describe('alertRuleToQueries', () => {
|
|||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
meta: {} as PluginMeta,
|
meta: {} as PluginMeta,
|
||||||
jsonData: {} as DataSourceJsonData,
|
jsonData: {} as DataSourceJsonData,
|
||||||
|
readOnly: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
412
public/app/features/correlations/CorrelationsPage.test.tsx
Normal file
412
public/app/features/correlations/CorrelationsPage.test.tsx
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { merge, uniqueId } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
import { DeepPartial } from 'react-hook-form';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||||
|
|
||||||
|
import { DataSourcePluginMeta } from '@grafana/data';
|
||||||
|
import { BackendSrv, FetchError, FetchResponse, setDataSourceSrv, BackendSrvRequest } from '@grafana/runtime';
|
||||||
|
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||||
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import { mockDataSource, MockDataSourceSrv } from '../alerting/unified/mocks';
|
||||||
|
|
||||||
|
import CorrelationsPage from './CorrelationsPage';
|
||||||
|
import { Correlation, CreateCorrelationParams } from './types';
|
||||||
|
|
||||||
|
function createFetchResponse<T>(overrides?: DeepPartial<FetchResponse>): FetchResponse<T> {
|
||||||
|
return merge(
|
||||||
|
{
|
||||||
|
data: undefined,
|
||||||
|
status: 200,
|
||||||
|
url: '',
|
||||||
|
config: { url: '' },
|
||||||
|
type: 'basic',
|
||||||
|
statusText: 'Ok',
|
||||||
|
redirected: false,
|
||||||
|
headers: {} as unknown as Headers,
|
||||||
|
ok: true,
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFetchError(overrides?: DeepPartial<FetchError>): FetchError {
|
||||||
|
return merge(
|
||||||
|
createFetchResponse(),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
ok: false,
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('app/core/services/context_srv');
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
contextSrv: jest.mocked(contextSrv),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithContext = async (
|
||||||
|
datasources: ConstructorParameters<typeof MockDataSourceSrv>[0] = {},
|
||||||
|
correlations: Correlation[] = []
|
||||||
|
) => {
|
||||||
|
const backend = {
|
||||||
|
delete: async (url: string) => {
|
||||||
|
const matches = url.match(
|
||||||
|
/^\/api\/datasources\/uid\/(?<dsUid>[a-zA-Z0-9]+)\/correlations\/(?<correlationUid>[a-zA-Z0-9]+)$/
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matches?.groups) {
|
||||||
|
const { dsUid, correlationUid } = matches.groups;
|
||||||
|
correlations = correlations.filter((c) => c.uid !== correlationUid || c.sourceUID !== dsUid);
|
||||||
|
return createFetchResponse({
|
||||||
|
data: {
|
||||||
|
message: 'Correlation deleted',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createFetchError({
|
||||||
|
data: {
|
||||||
|
message: 'Correlation not found',
|
||||||
|
},
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
post: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
|
||||||
|
const matches = url.match(/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations$/);
|
||||||
|
if (matches?.groups) {
|
||||||
|
const { sourceUID } = matches.groups;
|
||||||
|
const correlation = { sourceUID, ...data, uid: uniqueId() };
|
||||||
|
correlations.push(correlation);
|
||||||
|
return correlation;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createFetchError({
|
||||||
|
status: 404,
|
||||||
|
data: {
|
||||||
|
message: 'Source datasource not found',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
patch: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
|
||||||
|
const matches = url.match(
|
||||||
|
/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations\/(?<correlationUid>[a-zA-Z0-9]+)$/
|
||||||
|
);
|
||||||
|
if (matches?.groups) {
|
||||||
|
const { sourceUID, correlationUid } = matches.groups;
|
||||||
|
correlations = correlations.map((c) => {
|
||||||
|
if (c.uid === correlationUid && sourceUID === c.sourceUID) {
|
||||||
|
return { ...c, ...data };
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
return createFetchResponse({
|
||||||
|
data: { sourceUID, ...data },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createFetchError({
|
||||||
|
data: { message: 'either correlation uid or source id not found' },
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fetch: (options: BackendSrvRequest) => {
|
||||||
|
return new Observable((s) => {
|
||||||
|
if (correlations.length) {
|
||||||
|
s.next(merge(createFetchResponse({ url: options.url, data: correlations })));
|
||||||
|
} else {
|
||||||
|
s.error(merge(createFetchError({ config: { url: options.url }, status: 404 })));
|
||||||
|
}
|
||||||
|
s.complete();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as unknown as BackendSrv;
|
||||||
|
const grafanaContext = getGrafanaContextMock({ backend });
|
||||||
|
|
||||||
|
setDataSourceSrv(new MockDataSourceSrv(datasources));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Provider store={configureStore({})}>
|
||||||
|
<GrafanaContext.Provider value={grafanaContext}>
|
||||||
|
<CorrelationsPage />
|
||||||
|
</GrafanaContext.Provider>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mocks.contextSrv.hasPermission.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CorrelationsPage', () => {
|
||||||
|
describe('With no correlations', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await renderWithContext({
|
||||||
|
loki: mockDataSource(
|
||||||
|
{
|
||||||
|
uid: 'loki',
|
||||||
|
name: 'loki',
|
||||||
|
readOnly: false,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
{ logs: true }
|
||||||
|
),
|
||||||
|
prometheus: mockDataSource(
|
||||||
|
{
|
||||||
|
uid: 'prometheus',
|
||||||
|
name: 'prometheus',
|
||||||
|
readOnly: false,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
{ metrics: true }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows CTA', async () => {
|
||||||
|
// insert form should not be present
|
||||||
|
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// "add new" button is the button on the top of the page, not visible when the CTA is rendered
|
||||||
|
expect(screen.queryByRole('button', { name: /add new$/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// there's no table in the page
|
||||||
|
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const CTAButton = screen.getByRole('button', { name: /add correlation/i });
|
||||||
|
expect(CTAButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(CTAButton);
|
||||||
|
|
||||||
|
// form's submit button
|
||||||
|
expect(screen.getByRole('button', { name: /add$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly adds correlations', async () => {
|
||||||
|
const CTAButton = screen.getByRole('button', { name: /add correlation/i });
|
||||||
|
expect(CTAButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// there's no table in the page, as we are adding the first correlation
|
||||||
|
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(CTAButton);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'A Label' } });
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), { target: { value: 'A Description' } });
|
||||||
|
|
||||||
|
// set source datasource picker value
|
||||||
|
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
|
||||||
|
fireEvent.click(screen.getByText('loki'));
|
||||||
|
|
||||||
|
// set target datasource picker value
|
||||||
|
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
|
||||||
|
fireEvent.click(screen.getByText('prometheus'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
|
||||||
|
|
||||||
|
// Waits for the form to be removed, meaning the correlation got successfully saved
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// the table showing correlations should have appeared
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('With correlations', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await renderWithContext(
|
||||||
|
{
|
||||||
|
loki: mockDataSource(
|
||||||
|
{
|
||||||
|
uid: 'loki',
|
||||||
|
name: 'loki',
|
||||||
|
readOnly: false,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logs: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
prometheus: mockDataSource(
|
||||||
|
{
|
||||||
|
uid: 'prometheus',
|
||||||
|
name: 'prometheus',
|
||||||
|
readOnly: false,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metrics: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
elastic: mockDataSource(
|
||||||
|
{
|
||||||
|
uid: 'elastic',
|
||||||
|
name: 'elastic',
|
||||||
|
readOnly: false,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
type: 'datasource',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metrics: true,
|
||||||
|
logs: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
[{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a table with correlations', async () => {
|
||||||
|
await renderWithContext();
|
||||||
|
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly adds correlations', async () => {
|
||||||
|
const addNewButton = screen.getByRole('button', { name: /add new/i });
|
||||||
|
expect(addNewButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(addNewButton);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'A Label' } });
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), { target: { value: 'A Description' } });
|
||||||
|
|
||||||
|
// set source datasource picker value
|
||||||
|
fireEvent.keyDown(screen.getByLabelText(/^source$/i), { keyCode: 40 });
|
||||||
|
fireEvent.click(screen.getByText('prometheus'));
|
||||||
|
|
||||||
|
// set target datasource picker value
|
||||||
|
fireEvent.keyDown(screen.getByLabelText(/^target$/i), { keyCode: 40 });
|
||||||
|
fireEvent.click(screen.getByText('elastic'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add$/i }));
|
||||||
|
|
||||||
|
// the form should get removed after successful submissions
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly closes the form when clicking on the close icon', async () => {
|
||||||
|
const addNewButton = screen.getByRole('button', { name: /add new/i });
|
||||||
|
expect(addNewButton).toBeInTheDocument();
|
||||||
|
fireEvent.click(addNewButton);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /add$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly deletes correlations', async () => {
|
||||||
|
// A row with the correlation should exist
|
||||||
|
expect(screen.getByRole('cell', { name: /some label/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
const deleteButton = screen.getByRole('button', { name: /delete correlation/i });
|
||||||
|
|
||||||
|
expect(deleteButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole('button', { name: /delete$/i });
|
||||||
|
expect(confirmButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('cell', { name: /some label/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly edits correlations', async () => {
|
||||||
|
const rowExpanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
|
||||||
|
fireEvent.click(rowExpanderButton);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /label/i }), { target: { value: 'edited label' } });
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /description/i }), {
|
||||||
|
target: { value: 'edited description' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('cell', { name: /edited label$/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('cell', { name: /edited label$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Read only correlations', () => {
|
||||||
|
const correlations = [{ sourceUID: 'loki', targetUID: 'loki', uid: '1', label: 'Some label' }];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await renderWithContext(
|
||||||
|
{
|
||||||
|
loki: mockDataSource({
|
||||||
|
uid: 'loki',
|
||||||
|
name: 'loki',
|
||||||
|
readOnly: true,
|
||||||
|
jsonData: {},
|
||||||
|
access: 'direct',
|
||||||
|
meta: { info: { logos: {} } } as DataSourcePluginMeta,
|
||||||
|
type: 'datasource',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
correlations
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't render delete button", async () => {
|
||||||
|
// A row with the correlation should exist
|
||||||
|
expect(screen.getByRole('cell', { name: /some label/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /delete correlation/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edit form is read only', async () => {
|
||||||
|
// A row with the correlation should exist
|
||||||
|
const rowExpanderButton = screen.getByRole('button', { name: /toggle row expanded/i });
|
||||||
|
|
||||||
|
fireEvent.click(rowExpanderButton);
|
||||||
|
|
||||||
|
// form elements should be readonly
|
||||||
|
const labelInput = screen.getByRole('textbox', { name: /label/i });
|
||||||
|
expect(labelInput).toBeInTheDocument();
|
||||||
|
expect(labelInput).toHaveAttribute('readonly');
|
||||||
|
|
||||||
|
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
|
||||||
|
expect(descriptionInput).toBeInTheDocument();
|
||||||
|
expect(descriptionInput).toHaveAttribute('readonly');
|
||||||
|
|
||||||
|
// we don't expect the save button to be rendered
|
||||||
|
expect(screen.queryByRole('button', { name: 'save' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
215
public/app/features/correlations/CorrelationsPage.tsx
Normal file
215
public/app/features/correlations/CorrelationsPage.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { negate } from 'lodash';
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CellProps, SortByFn } from 'react-table';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Badge, Button, DeleteButton, HorizontalGroup, LoadingPlaceholder, useStyles2, Alert } from '@grafana/ui';
|
||||||
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
import { useNavModel } from 'app/core/hooks/useNavModel';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
|
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
|
||||||
|
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
|
||||||
|
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
|
||||||
|
import { Column, Table } from './components/Table';
|
||||||
|
import { RemoveCorrelationParams } from './types';
|
||||||
|
import { CorrelationData, useCorrelations } from './useCorrelations';
|
||||||
|
|
||||||
|
const sortDatasource: SortByFn<CorrelationData> = (a, b, column) =>
|
||||||
|
a.values[column].name.localeCompare(b.values[column].name);
|
||||||
|
|
||||||
|
const isSourceReadOnly = ({ source }: Pick<CorrelationData, 'source'>) => source.readOnly;
|
||||||
|
|
||||||
|
const loaderWrapper = css`
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function CorrelationsPage() {
|
||||||
|
const navModel = useNavModel('correlations');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const { remove, get } = useCorrelations();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get.execute();
|
||||||
|
// we only want to fetch data on first render
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canWriteCorrelations = contextSrv.hasPermission(AccessControlAction.DataSourcesWrite);
|
||||||
|
|
||||||
|
const handleAdd = useCallback(() => {
|
||||||
|
get.execute();
|
||||||
|
setIsAdding(false);
|
||||||
|
}, [get]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(() => {
|
||||||
|
get.execute();
|
||||||
|
}, [get]);
|
||||||
|
|
||||||
|
const handleRemove = useCallback<(params: RemoveCorrelationParams) => void>(
|
||||||
|
async (correlation) => {
|
||||||
|
await remove.execute(correlation);
|
||||||
|
get.execute();
|
||||||
|
},
|
||||||
|
[remove, get]
|
||||||
|
);
|
||||||
|
|
||||||
|
const RowActions = useCallback(
|
||||||
|
({
|
||||||
|
row: {
|
||||||
|
original: {
|
||||||
|
source: { uid: sourceUID, readOnly },
|
||||||
|
uid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}: CellProps<CorrelationData, void>) =>
|
||||||
|
!readOnly && (
|
||||||
|
<DeleteButton
|
||||||
|
aria-label="delete correlation"
|
||||||
|
onConfirm={() => handleRemove({ sourceUID, uid })}
|
||||||
|
closeOnConfirm
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleRemove]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<Array<Column<CorrelationData>>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
cell: InfoCell,
|
||||||
|
shrink: true,
|
||||||
|
visible: (data) => data.some(isSourceReadOnly),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
header: 'Source',
|
||||||
|
cell: DataSourceCell,
|
||||||
|
sortType: sortDatasource,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'target',
|
||||||
|
header: 'Target',
|
||||||
|
cell: DataSourceCell,
|
||||||
|
sortType: sortDatasource,
|
||||||
|
},
|
||||||
|
{ id: 'label', header: 'Label', sortType: 'alphanumeric' },
|
||||||
|
{
|
||||||
|
cell: RowActions,
|
||||||
|
shrink: true,
|
||||||
|
visible: (data) => canWriteCorrelations && data.some(negate(isSourceReadOnly)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[RowActions, canWriteCorrelations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = useMemo(() => get.value, [get.value]);
|
||||||
|
|
||||||
|
const showEmptyListCTA = data?.length === 0 && !isAdding && (!get.error || get.error.status === 404);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navModel={navModel}>
|
||||||
|
<Page.Contents>
|
||||||
|
<div>
|
||||||
|
<HorizontalGroup justify="space-between">
|
||||||
|
<div>
|
||||||
|
<h4>Correlations</h4>
|
||||||
|
<p>Define how data living in different data sources relates to each other.</p>
|
||||||
|
</div>
|
||||||
|
{canWriteCorrelations && data?.length !== 0 && data !== undefined && !isAdding && (
|
||||||
|
<Button icon="plus" onClick={() => setIsAdding(true)}>
|
||||||
|
Add new
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HorizontalGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!data && get.loading && (
|
||||||
|
<div className={loaderWrapper}>
|
||||||
|
<LoadingPlaceholder text="loading..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmptyListCTA && <EmptyCorrelationsCTA onClick={() => setIsAdding(true)} />}
|
||||||
|
|
||||||
|
{
|
||||||
|
// This error is not actionable, it'd be nice to have a recovery button
|
||||||
|
get.error && get.error.status !== 404 && (
|
||||||
|
<Alert severity="error" title="Error fetching correlation data" topSpacing={2}>
|
||||||
|
<HorizontalGroup>
|
||||||
|
{get.error.data.message ||
|
||||||
|
'An unknown error occurred while fetching correlation data. Please try again.'}
|
||||||
|
</HorizontalGroup>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{isAdding && <AddCorrelationForm onClose={() => setIsAdding(false)} onCreated={handleAdd} />}
|
||||||
|
|
||||||
|
{data && data.length >= 1 && (
|
||||||
|
<Table
|
||||||
|
renderExpandedRow={({ target, source, ...correlation }) => (
|
||||||
|
<EditCorrelationForm
|
||||||
|
defaultValues={{ sourceUID: source.uid, ...correlation }}
|
||||||
|
onUpdated={handleUpdate}
|
||||||
|
readOnly={isSourceReadOnly({ source }) || !canWriteCorrelations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
getRowId={(correlation) => `${correlation.source.uid}-${correlation.uid}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
root: css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`,
|
||||||
|
dsLogo: css`
|
||||||
|
margin-right: ${theme.spacing()};
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DataSourceCell = memo(
|
||||||
|
function DataSourceCell({
|
||||||
|
cell: { value },
|
||||||
|
}: CellProps<CorrelationData, CorrelationData['source'] | CorrelationData['target']>) {
|
||||||
|
const styles = useStyles2(getDatasourceCellStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.root}>
|
||||||
|
<img src={value.meta.info.logos.small} className={styles.dsLogo} />
|
||||||
|
{value.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
({ cell: { value } }, { cell: { value: prevValue } }) => {
|
||||||
|
return value.type === prevValue.type && value.name === prevValue.name;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const noWrap = css`
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoCell = memo(
|
||||||
|
function InfoCell({ ...props }: CellProps<CorrelationData, void>) {
|
||||||
|
const readOnly = props.row.original.source.readOnly;
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return <Badge text="Read only" color="purple" className={noWrap} />;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(props, prevProps) => props.row.original.source.readOnly === prevProps.row.original.source.readOnly
|
||||||
|
);
|
123
public/app/features/correlations/Forms/AddCorrelationForm.tsx
Normal file
123
public/app/features/correlations/Forms/AddCorrelationForm.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { DataSourcePicker } from '@grafana/runtime';
|
||||||
|
import { Button, Field, HorizontalGroup, PanelContainer, useStyles2 } from '@grafana/ui';
|
||||||
|
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
|
||||||
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
|
||||||
|
import { useCorrelations } from '../useCorrelations';
|
||||||
|
|
||||||
|
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
|
||||||
|
import { FormDTO } from './types';
|
||||||
|
import { useCorrelationForm } from './useCorrelationForm';
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
panelContainer: css`
|
||||||
|
position: relative;
|
||||||
|
padding: ${theme.spacing(1)};
|
||||||
|
margin-bottom: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
linksToContainer: css`
|
||||||
|
flex-grow: 1;
|
||||||
|
/* This is the width of the textarea minus the sum of the label&description fields,
|
||||||
|
* so that this element takes exactly the remaining space and the inputs will be
|
||||||
|
* nicely aligned with the textarea
|
||||||
|
**/
|
||||||
|
max-width: ${theme.spacing(80 - 64)};
|
||||||
|
margin-top: ${theme.spacing(3)};
|
||||||
|
text-align: right;
|
||||||
|
padding-right: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
// we can't use HorizontalGroup because it wraps elements in divs and sets margins on them
|
||||||
|
horizontalGroup: css`
|
||||||
|
display: flex;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
|
||||||
|
|
||||||
|
export const AddCorrelationForm = ({ onClose, onCreated }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const { create } = useCorrelations();
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (correlation) => {
|
||||||
|
await create.execute(correlation);
|
||||||
|
onCreated();
|
||||||
|
},
|
||||||
|
[create, onCreated]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { control, handleSubmit, register, errors } = useCorrelationForm<FormDTO>({ onSubmit });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelContainer className={styles.panelContainer}>
|
||||||
|
<CloseButton onClick={onClose} />
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.horizontalGroup}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="sourceUID"
|
||||||
|
rules={{
|
||||||
|
required: { value: true, message: 'This field is required.' },
|
||||||
|
validate: {
|
||||||
|
writable: (uid: string) =>
|
||||||
|
!getDatasourceSrv().getInstanceSettings(uid)?.readOnly || "Source can't be a read-only data source.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Field label="Source" htmlFor="source" invalid={!!errors.sourceUID} error={errors.sourceUID?.message}>
|
||||||
|
<DataSourcePicker
|
||||||
|
onChange={withDsUID(onChange)}
|
||||||
|
noDefault
|
||||||
|
current={value}
|
||||||
|
inputId="source"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className={styles.linksToContainer}>Links to</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="targetUID"
|
||||||
|
rules={{ required: { value: true, message: 'This field is required.' } }}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Field label="Target" htmlFor="target" invalid={!!errors.targetUID} error={errors.targetUID?.message}>
|
||||||
|
<DataSourcePicker
|
||||||
|
onChange={withDsUID(onChange)}
|
||||||
|
noDefault
|
||||||
|
current={value}
|
||||||
|
inputId="target"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CorrelationDetailsFormPart register={register} />
|
||||||
|
|
||||||
|
<HorizontalGroup justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={create.loading ? 'fa fa-spinner' : 'plus'}
|
||||||
|
type="submit"
|
||||||
|
disabled={create.loading}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</form>
|
||||||
|
</PanelContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,59 @@
|
|||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
import { RegisterOptions, UseFormRegisterReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Field, Input, TextArea, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { EditFormDTO } from './types';
|
||||||
|
|
||||||
|
const getInputId = (inputName: string, correlation?: EditFormDTO) => {
|
||||||
|
if (!correlation) {
|
||||||
|
return inputName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${inputName}_${correlation.sourceUID}-${correlation.uid}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
marginless: css`
|
||||||
|
margin: 0;
|
||||||
|
`,
|
||||||
|
label: css`
|
||||||
|
max-width: ${theme.spacing(32)};
|
||||||
|
`,
|
||||||
|
description: css`
|
||||||
|
max-width: ${theme.spacing(80)};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
register: (path: 'label' | 'description', options?: RegisterOptions) => UseFormRegisterReturn;
|
||||||
|
readOnly?: boolean;
|
||||||
|
correlation?: EditFormDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorrelationDetailsFormPart({ register, readOnly = false, correlation }: Props) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Label" className={styles.label}>
|
||||||
|
<Input
|
||||||
|
id={getInputId('label', correlation)}
|
||||||
|
{...register('label')}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder="i.e. Tempo traces"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
// the Field component automatically adds margin to itself, so we are forced to workaround it by overriding its styles
|
||||||
|
className={cx(readOnly && styles.marginless, styles.description)}
|
||||||
|
>
|
||||||
|
<TextArea id={getInputId('description', correlation)} {...register('description')} readOnly={readOnly} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { Button, HorizontalGroup } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { useCorrelations } from '../useCorrelations';
|
||||||
|
|
||||||
|
import { CorrelationDetailsFormPart } from './CorrelationDetailsFormPart';
|
||||||
|
import { EditFormDTO } from './types';
|
||||||
|
import { useCorrelationForm } from './useCorrelationForm';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onUpdated: () => void;
|
||||||
|
defaultValues: EditFormDTO;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCorrelationForm = ({ onUpdated, defaultValues, readOnly = false }: Props) => {
|
||||||
|
const { update } = useCorrelations();
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (correlation) => {
|
||||||
|
await update.execute(correlation);
|
||||||
|
onUpdated();
|
||||||
|
},
|
||||||
|
[update, onUpdated]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handleSubmit, register } = useCorrelationForm<EditFormDTO>({ onSubmit, defaultValues });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={readOnly ? (e) => e.preventDefault() : handleSubmit}>
|
||||||
|
<input type="hidden" {...register('uid')} />
|
||||||
|
<input type="hidden" {...register('sourceUID')} />
|
||||||
|
<CorrelationDetailsFormPart register={register} readOnly={readOnly} correlation={defaultValues} />
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<HorizontalGroup justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={update.loading ? 'fa fa-spinner' : 'save'}
|
||||||
|
type="submit"
|
||||||
|
disabled={update.loading}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
11
public/app/features/correlations/Forms/types.ts
Normal file
11
public/app/features/correlations/Forms/types.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Correlation } from '../types';
|
||||||
|
|
||||||
|
export interface FormDTO {
|
||||||
|
sourceUID: string;
|
||||||
|
targetUID: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormDTOWithoutTarget = Omit<FormDTO, 'targetUID'>;
|
||||||
|
export type EditFormDTO = Partial<FormDTOWithoutTarget> & Pick<FormDTO, 'sourceUID'> & { uid: Correlation['uid'] };
|
18
public/app/features/correlations/Forms/useCorrelationForm.ts
Normal file
18
public/app/features/correlations/Forms/useCorrelationForm.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { DeepPartial, SubmitHandler, UnpackNestedValue, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
interface UseCorrelationFormOptions<T> {
|
||||||
|
onSubmit: SubmitHandler<T>;
|
||||||
|
defaultValues?: UnpackNestedValue<DeepPartial<T>>;
|
||||||
|
}
|
||||||
|
export const useCorrelationForm = <T>({ onSubmit, defaultValues }: UseCorrelationFormOptions<T>) => {
|
||||||
|
const {
|
||||||
|
handleSubmit: submit,
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<T>({ defaultValues });
|
||||||
|
|
||||||
|
const handleSubmit = submit(onSubmit);
|
||||||
|
|
||||||
|
return { control, handleSubmit, register, errors };
|
||||||
|
};
|
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
export const EmptyCorrelationsCTA = ({ onClick }: Props) => {
|
||||||
|
// TODO: if there are no datasources show a different message
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmptyListCTA
|
||||||
|
title="You haven't defined any correlation yet."
|
||||||
|
buttonIcon="gf-glue"
|
||||||
|
onClick={onClick}
|
||||||
|
buttonTitle="Add correlation"
|
||||||
|
proTip="you can also define correlations via datasource provisioning"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,22 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
import { CellProps } from 'react-table';
|
||||||
|
|
||||||
|
import { IconButton } from '@grafana/ui';
|
||||||
|
|
||||||
|
const expanderContainerStyles = css`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ExpanderCell = ({ row }: CellProps<object, void>) => (
|
||||||
|
<div className={expanderContainerStyles}>
|
||||||
|
<IconButton
|
||||||
|
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
|
||||||
|
name={row.isExpanded ? 'angle-down' : 'angle-right'}
|
||||||
|
// @ts-expect-error same as the line above
|
||||||
|
{...row.getToggleRowExpandedProps({})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
161
public/app/features/correlations/components/Table/index.tsx
Normal file
161
public/app/features/correlations/components/Table/index.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { cx, css } from '@emotion/css';
|
||||||
|
import React, { useMemo, Fragment, ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
CellProps,
|
||||||
|
SortByFn,
|
||||||
|
useExpanded,
|
||||||
|
useSortBy,
|
||||||
|
useTable,
|
||||||
|
DefaultSortTypes,
|
||||||
|
TableOptions,
|
||||||
|
IdType,
|
||||||
|
} from 'react-table';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
import { isTruthy } from 'app/core/utils/types';
|
||||||
|
|
||||||
|
import { EXPANDER_CELL_ID, getColumns } from './utils';
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
table: css`
|
||||||
|
border-radius: ${theme.shape.borderRadius()};
|
||||||
|
border: solid 1px ${theme.colors.border.weak};
|
||||||
|
background-color: ${theme.colors.background.secondary};
|
||||||
|
width: 100%;
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding: ${theme.spacing(1)};
|
||||||
|
min-width: ${theme.spacing(3)};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
evenRow: css`
|
||||||
|
background: ${theme.colors.background.primary};
|
||||||
|
`,
|
||||||
|
shrink: css`
|
||||||
|
width: 0%;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Column<TableData extends object> {
|
||||||
|
/**
|
||||||
|
* ID of the column.
|
||||||
|
* Set this to the matching object key of your data or `undefined` if the column doesn't have any associated data with it.
|
||||||
|
* This must be unique among all other columns.
|
||||||
|
*/
|
||||||
|
id?: IdType<TableData>;
|
||||||
|
cell?: (props: CellProps<TableData>) => ReactNode;
|
||||||
|
header?: (() => ReactNode | string) | string;
|
||||||
|
sortType?: DefaultSortTypes | SortByFn<TableData>;
|
||||||
|
shrink?: boolean;
|
||||||
|
visible?: (col: TableData[]) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<TableData extends object> {
|
||||||
|
columns: Array<Column<TableData>>;
|
||||||
|
data: TableData[];
|
||||||
|
renderExpandedRow?: (row: TableData) => JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
getRowId: TableOptions<TableData>['getRowId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* non-viz table component.
|
||||||
|
* Will need most likely to be moved in @grafana/ui
|
||||||
|
*/
|
||||||
|
export function Table<TableData extends object>({
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
columns,
|
||||||
|
renderExpandedRow,
|
||||||
|
getRowId,
|
||||||
|
}: Props<TableData>) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const tableColumns = useMemo(() => {
|
||||||
|
const cols = getColumns<TableData>(columns);
|
||||||
|
return cols;
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable<TableData>(
|
||||||
|
{
|
||||||
|
columns: tableColumns,
|
||||||
|
data,
|
||||||
|
autoResetExpanded: false,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
getRowId,
|
||||||
|
initialState: {
|
||||||
|
hiddenColumns: [
|
||||||
|
!renderExpandedRow && EXPANDER_CELL_ID,
|
||||||
|
...tableColumns
|
||||||
|
.filter((col) => !(col.visible?.(data) ?? true))
|
||||||
|
.map((c) => c.id)
|
||||||
|
.filter(isTruthy),
|
||||||
|
].filter(isTruthy),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useExpanded
|
||||||
|
);
|
||||||
|
// This should be called only for rows thar we'd want to actually render, which is all at this stage.
|
||||||
|
// We may want to revisit this if we decide to add pagination and/or virtualized tables.
|
||||||
|
rows.forEach(prepareRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table {...getTableProps()} className={cx(styles.table, className)}>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, ...headerRowProps } = headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={key} {...headerRowProps}>
|
||||||
|
{headerGroup.headers.map((column) => {
|
||||||
|
// TODO: if the column is a function, it should also provide an accessible name as a string to be used a the column title in getSortByToggleProps
|
||||||
|
const { key, ...headerCellProps } = column.getHeaderProps(
|
||||||
|
column.canSort ? column.getSortByToggleProps() : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th key={key} className={cx(column.width === 0 && styles.shrink)} {...headerCellProps}>
|
||||||
|
{column.render('Header')}
|
||||||
|
|
||||||
|
{column.isSorted && <Icon name={column.isSortedDesc ? 'angle-down' : 'angle-up'} />}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody {...getTableBodyProps()}>
|
||||||
|
{rows.map((row, rowIndex) => {
|
||||||
|
const className = cx(rowIndex % 2 === 0 && styles.evenRow);
|
||||||
|
const { key, ...otherRowProps } = row.getRowProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<tr className={className} {...otherRowProps}>
|
||||||
|
{row.cells.map((cell) => {
|
||||||
|
const { key, ...otherCellProps } = cell.getCellProps();
|
||||||
|
return (
|
||||||
|
<td key={key} {...otherCellProps}>
|
||||||
|
{cell.render('Cell')}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{
|
||||||
|
// @ts-expect-error react-table doesn't ship with useExpanded types and we can't use declaration merging without affecting the table viz
|
||||||
|
row.isExpanded && renderExpandedRow && (
|
||||||
|
<tr className={className} {...otherRowProps}>
|
||||||
|
<td colSpan={row.cells.length}>{renderExpandedRow(row.original)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
36
public/app/features/correlations/components/Table/utils.ts
Normal file
36
public/app/features/correlations/components/Table/utils.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { uniqueId } from 'lodash';
|
||||||
|
import { Column as RTColumn } from 'react-table';
|
||||||
|
|
||||||
|
import { ExpanderCell } from './ExpanderCell';
|
||||||
|
|
||||||
|
import { Column } from '.';
|
||||||
|
|
||||||
|
export const EXPANDER_CELL_ID = '__expander';
|
||||||
|
|
||||||
|
type InternalColumn<T extends object> = RTColumn<T> & {
|
||||||
|
visible?: (data: T[]) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the columns in a "react-table" acceptable format
|
||||||
|
export function getColumns<K extends object>(columns: Array<Column<K>>): Array<InternalColumn<K>> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: EXPANDER_CELL_ID,
|
||||||
|
Cell: ExpanderCell,
|
||||||
|
disableSortBy: true,
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
|
// @ts-expect-error react-table expects each column key(id) to have data associated with it and therefore complains about
|
||||||
|
// column.id being possibly undefined and not keyof T (where T is the data object)
|
||||||
|
// We do not want to be that strict as we simply pass undefined to cells that do not have data associated with them.
|
||||||
|
...columns.map((column) => ({
|
||||||
|
Header: column.header || (() => null),
|
||||||
|
accessor: column.id || uniqueId(),
|
||||||
|
sortType: column.sortType || 'alphanumeric',
|
||||||
|
disableSortBy: !Boolean(column.sortType),
|
||||||
|
width: column.shrink ? 0 : undefined,
|
||||||
|
visible: column.visible,
|
||||||
|
...(column.cell && { Cell: column.cell }),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
17
public/app/features/correlations/types.ts
Normal file
17
public/app/features/correlations/types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface AddCorrelationResponse {
|
||||||
|
correlation: Correlation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetCorrelationsResponse = Correlation[];
|
||||||
|
|
||||||
|
export interface Correlation {
|
||||||
|
uid: string;
|
||||||
|
sourceUID: string;
|
||||||
|
targetUID: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoveCorrelationParams = Pick<Correlation, 'sourceUID' | 'uid'>;
|
||||||
|
export type CreateCorrelationParams = Omit<Correlation, 'uid'>;
|
||||||
|
export type UpdateCorrelationParams = Omit<Correlation, 'targetUID'>;
|
88
public/app/features/correlations/useCorrelations.ts
Normal file
88
public/app/features/correlations/useCorrelations.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAsyncFn } from 'react-use';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||||
|
import { getDataSourceSrv, FetchResponse, FetchError } from '@grafana/runtime';
|
||||||
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
|
|
||||||
|
import { Correlation, CreateCorrelationParams, RemoveCorrelationParams, UpdateCorrelationParams } from './types';
|
||||||
|
|
||||||
|
export interface CorrelationData extends Omit<Correlation, 'sourceUID' | 'targetUID'> {
|
||||||
|
source: DataSourceInstanceSettings;
|
||||||
|
target: DataSourceInstanceSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEnrichedCorrelationData = ({ sourceUID, targetUID, ...correlation }: Correlation): CorrelationData => ({
|
||||||
|
...correlation,
|
||||||
|
source: getDataSourceSrv().getInstanceSettings(sourceUID)!,
|
||||||
|
target: getDataSourceSrv().getInstanceSettings(targetUID)!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toEnrichedCorrelationsData = (correlations: Correlation[]) => correlations.map(toEnrichedCorrelationData);
|
||||||
|
function getData<T>(response: FetchResponse<T>) {
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hook for managing correlations data.
|
||||||
|
* TODO: ideally this hook shouldn't have any side effect like showing notifications on error
|
||||||
|
* and let consumers handle them. It works nicely with the correlations settings page, but when we'll
|
||||||
|
* expose this we'll have to remove those side effects.
|
||||||
|
*/
|
||||||
|
export const useCorrelations = () => {
|
||||||
|
const { backend } = useGrafana();
|
||||||
|
const [error, setError] = useState<FetchError | null>(null);
|
||||||
|
|
||||||
|
const [getInfo, get] = useAsyncFn<() => Promise<CorrelationData[]>>(
|
||||||
|
() =>
|
||||||
|
lastValueFrom(
|
||||||
|
backend.fetch<Correlation[]>({ url: '/api/datasources/correlations', method: 'GET', showErrorAlert: false })
|
||||||
|
)
|
||||||
|
.then(getData, (e) => {
|
||||||
|
setError(e);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.then(toEnrichedCorrelationsData),
|
||||||
|
[backend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [createInfo, create] = useAsyncFn<(params: CreateCorrelationParams) => Promise<CorrelationData>>(
|
||||||
|
({ sourceUID, ...correlation }) =>
|
||||||
|
backend.post(`/api/datasources/uid/${sourceUID}/correlations`, correlation).then(toEnrichedCorrelationData),
|
||||||
|
[backend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [removeInfo, remove] = useAsyncFn<(params: RemoveCorrelationParams) => Promise<void>>(
|
||||||
|
({ sourceUID, uid }) => backend.delete(`/api/datasources/uid/${sourceUID}/correlations/${uid}`),
|
||||||
|
[backend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updateInfo, update] = useAsyncFn<(params: UpdateCorrelationParams) => Promise<CorrelationData>>(
|
||||||
|
({ sourceUID, uid, ...correlation }) =>
|
||||||
|
backend
|
||||||
|
.patch(`/api/datasources/uid/${sourceUID}/correlations/${uid}`, correlation)
|
||||||
|
.then(toEnrichedCorrelationData),
|
||||||
|
[backend]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: {
|
||||||
|
execute: create,
|
||||||
|
...createInfo,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
execute: update,
|
||||||
|
...updateInfo,
|
||||||
|
},
|
||||||
|
get: {
|
||||||
|
execute: get,
|
||||||
|
...getInfo,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
execute: remove,
|
||||||
|
...removeInfo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -1,7 +1,6 @@
|
|||||||
import { within } from '@testing-library/dom';
|
import { within } from '@testing-library/dom';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||||
@ -52,7 +51,7 @@ function setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('VersionSettings', () => {
|
describe('VersionSettings', () => {
|
||||||
let user: UserEvent;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Need to use delay: null here to work with fakeTimers
|
// Need to use delay: null here to work with fakeTimers
|
||||||
|
@ -153,7 +153,9 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
|
|||||||
tabs={
|
tabs={
|
||||||
<TabsBar>
|
<TabsBar>
|
||||||
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
|
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
|
||||||
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
|
{data.hasChanges && (
|
||||||
|
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
|
||||||
|
)}
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
}
|
}
|
||||||
expandable
|
expandable
|
||||||
|
@ -119,4 +119,33 @@ describe('SaveDashboardAsForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('saved message draft rendered', () => {
|
||||||
|
it('renders saved message draft if it was filled before', () => {
|
||||||
|
render(
|
||||||
|
<SaveDashboardForm
|
||||||
|
dashboard={new DashboardModel({})}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
onSubmit={async () => {
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
saveModel={{
|
||||||
|
clone: new DashboardModel({}),
|
||||||
|
diff: {},
|
||||||
|
diffCount: 0,
|
||||||
|
hasChanges: true,
|
||||||
|
}}
|
||||||
|
options={{ message: 'Saved draft' }}
|
||||||
|
onOptionsChange={(opts: SaveDashboardOptions) => {
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageTextArea = screen.getByLabelText('message');
|
||||||
|
|
||||||
|
expect(messageTextArea).toBeInTheDocument();
|
||||||
|
expect(messageTextArea).toHaveTextContent('Saved draft');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -56,53 +56,69 @@ export const SaveDashboardForm = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ register, errors }) => (
|
{({ register, errors }) => {
|
||||||
<Stack direction="column" gap={2}>
|
const messageProps = register('message');
|
||||||
{hasTimeChanged && (
|
return (
|
||||||
<Checkbox
|
<Stack direction="column" gap={2}>
|
||||||
checked={!!options.saveTimerange}
|
{hasTimeChanged && (
|
||||||
onChange={() =>
|
<Checkbox
|
||||||
|
checked={!!options.saveTimerange}
|
||||||
|
onChange={() =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
saveTimerange: !options.saveTimerange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Save current time range as dashboard default"
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasVariableChanged && (
|
||||||
|
<Checkbox
|
||||||
|
checked={!!options.saveVariables}
|
||||||
|
onChange={() =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
saveVariables: !options.saveVariables,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Save current variable values as dashboard default"
|
||||||
|
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextArea
|
||||||
|
{...messageProps}
|
||||||
|
aria-label="message"
|
||||||
|
value={options.message}
|
||||||
|
onChange={(e) => {
|
||||||
onOptionsChange({
|
onOptionsChange({
|
||||||
...options,
|
...options,
|
||||||
saveTimerange: !options.saveTimerange,
|
message: e.currentTarget.value,
|
||||||
})
|
});
|
||||||
}
|
messageProps.onChange(e);
|
||||||
label="Save current time range as dashboard default"
|
}}
|
||||||
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
|
placeholder="Add a note to describe your changes."
|
||||||
|
autoFocus
|
||||||
|
rows={5}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{hasVariableChanged && (
|
|
||||||
<Checkbox
|
|
||||||
checked={!!options.saveVariables}
|
|
||||||
onChange={() =>
|
|
||||||
onOptionsChange({
|
|
||||||
...options,
|
|
||||||
saveVariables: !options.saveVariables,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
label="Save current variable values as dashboard default"
|
|
||||||
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
|
<Stack alignItems="center">
|
||||||
|
<Button variant="secondary" onClick={onCancel} fill="outline">
|
||||||
<Stack alignItems="center">
|
Cancel
|
||||||
<Button variant="secondary" onClick={onCancel} fill="outline">
|
</Button>
|
||||||
Cancel
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
<Button
|
disabled={!saveModel.hasChanges}
|
||||||
type="submit"
|
icon={saving ? 'fa fa-spinner' : undefined}
|
||||||
disabled={!saveModel.hasChanges}
|
aria-label={selectors.pages.SaveDashboardModal.save}
|
||||||
icon={saving ? 'fa fa-spinner' : undefined}
|
>
|
||||||
aria-label={selectors.pages.SaveDashboardModal.save}
|
Save
|
||||||
>
|
</Button>
|
||||||
Save
|
{!saveModel.hasChanges && <div>No changes to save</div>}
|
||||||
</Button>
|
</Stack>
|
||||||
{!saveModel.hasChanges && <div>No changes to save</div>}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
);
|
||||||
)}
|
}}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -37,7 +37,7 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
|
|||||||
const key = `${link.title}-$${index}`;
|
const key = `${link.title}-$${index}`;
|
||||||
|
|
||||||
if (link.type === 'dashboards') {
|
if (link.type === 'dashboards') {
|
||||||
return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardId={dashboard.id} />;
|
return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={dashboard.uid} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkElement = (
|
const linkElement = (
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DashboardSearchHit, DashboardSearchItemType } from '../../../search/types';
|
import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
import { DashboardLink } from '../../state/DashboardModel';
|
||||||
|
|
||||||
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
|
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
|
||||||
@ -39,7 +39,7 @@ describe('searchForTags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveLinks', () => {
|
describe('resolveLinks', () => {
|
||||||
const setupTestContext = (dashboardId: number, searchHitId: number) => {
|
const setupTestContext = (dashboardUID: string, searchHitId: string) => {
|
||||||
const link: DashboardLink = {
|
const link: DashboardLink = {
|
||||||
targetBlank: false,
|
targetBlank: false,
|
||||||
keepTime: false,
|
keepTime: false,
|
||||||
@ -52,9 +52,9 @@ describe('resolveLinks', () => {
|
|||||||
type: 'dashboards',
|
type: 'dashboards',
|
||||||
url: '/d/6ieouugGk/DashLinks',
|
url: '/d/6ieouugGk/DashLinks',
|
||||||
};
|
};
|
||||||
const searchHits: DashboardSearchHit[] = [
|
const searchHits: DashboardSearchItem[] = [
|
||||||
{
|
{
|
||||||
id: searchHitId,
|
uid: searchHitId,
|
||||||
title: 'DashLinks',
|
title: 'DashLinks',
|
||||||
url: '/d/6ieouugGk/DashLinks',
|
url: '/d/6ieouugGk/DashLinks',
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
@ -70,14 +70,18 @@ describe('resolveLinks', () => {
|
|||||||
const sanitize = jest.fn((args) => args);
|
const sanitize = jest.fn((args) => args);
|
||||||
const sanitizeUrl = jest.fn((args) => args);
|
const sanitizeUrl = jest.fn((args) => args);
|
||||||
|
|
||||||
return { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl };
|
return { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('when called', () => {
|
describe('when called', () => {
|
||||||
it('should filter out the calling dashboardId', () => {
|
it('should filter out the calling dashboardUID', () => {
|
||||||
const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 1);
|
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '1');
|
||||||
|
|
||||||
const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl });
|
const results = resolveLinks(dashboardUID, link, searchHits, {
|
||||||
|
getLinkSrv: () => linkSrv,
|
||||||
|
sanitize,
|
||||||
|
sanitizeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
expect(results.length).toEqual(0);
|
expect(results.length).toEqual(0);
|
||||||
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0);
|
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0);
|
||||||
@ -86,9 +90,13 @@ describe('resolveLinks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve link url', () => {
|
it('should resolve link url', () => {
|
||||||
const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2);
|
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
|
||||||
|
|
||||||
const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl });
|
const results = resolveLinks(dashboardUID, link, searchHits, {
|
||||||
|
getLinkSrv: () => linkSrv,
|
||||||
|
sanitize,
|
||||||
|
sanitizeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
expect(results.length).toEqual(1);
|
expect(results.length).toEqual(1);
|
||||||
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1);
|
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1);
|
||||||
@ -96,9 +104,13 @@ describe('resolveLinks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should sanitize title', () => {
|
it('should sanitize title', () => {
|
||||||
const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2);
|
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
|
||||||
|
|
||||||
const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl });
|
const results = resolveLinks(dashboardUID, link, searchHits, {
|
||||||
|
getLinkSrv: () => linkSrv,
|
||||||
|
sanitize,
|
||||||
|
sanitizeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
expect(results.length).toEqual(1);
|
expect(results.length).toEqual(1);
|
||||||
expect(sanitize).toHaveBeenCalledTimes(1);
|
expect(sanitize).toHaveBeenCalledTimes(1);
|
||||||
@ -106,9 +118,13 @@ describe('resolveLinks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should sanitize url', () => {
|
it('should sanitize url', () => {
|
||||||
const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2);
|
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
|
||||||
|
|
||||||
const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl });
|
const results = resolveLinks(dashboardUID, link, searchHits, {
|
||||||
|
getLinkSrv: () => linkSrv,
|
||||||
|
sanitize,
|
||||||
|
sanitizeUrl,
|
||||||
|
});
|
||||||
|
|
||||||
expect(results.length).toEqual(1);
|
expect(results.length).toEqual(1);
|
||||||
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
|
||||||
|
@ -7,7 +7,7 @@ import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
|||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
|
import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
import { DashboardSearchHit } from 'app/features/search/types';
|
import { DashboardSearchItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
||||||
import { DashboardLink } from '../../state/DashboardModel';
|
import { DashboardLink } from '../../state/DashboardModel';
|
||||||
@ -15,7 +15,7 @@ import { DashboardLink } from '../../state/DashboardModel';
|
|||||||
interface Props {
|
interface Props {
|
||||||
link: DashboardLink;
|
link: DashboardLink;
|
||||||
linkInfo: { title: string; href: string };
|
linkInfo: { title: string; href: string };
|
||||||
dashboardId: number;
|
dashboardUID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardLinksDashboard = (props: Props) => {
|
export const DashboardLinksDashboard = (props: Props) => {
|
||||||
@ -55,7 +55,7 @@ export const DashboardLinksDashboard = (props: Props) => {
|
|||||||
{resolvedLinks.length > 0 &&
|
{resolvedLinks.length > 0 &&
|
||||||
resolvedLinks.map((resolvedLink, index) => {
|
resolvedLinks.map((resolvedLink, index) => {
|
||||||
return (
|
return (
|
||||||
<li role="none" key={`dashlinks-dropdown-item-${resolvedLink.id}-${index}`}>
|
<li role="none" key={`dashlinks-dropdown-item-${resolvedLink.uid}-${index}`}>
|
||||||
<a
|
<a
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
href={resolvedLink.url}
|
href={resolvedLink.url}
|
||||||
@ -82,7 +82,7 @@ export const DashboardLinksDashboard = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<LinkElement
|
<LinkElement
|
||||||
link={link}
|
link={link}
|
||||||
key={`dashlinks-list-item-${resolvedLink.id}-${index}`}
|
key={`dashlinks-list-item-${resolvedLink.uid}-${index}`}
|
||||||
data-testid={selectors.components.DashboardLinks.container}
|
data-testid={selectors.components.DashboardLinks.container}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@ -120,17 +120,17 @@ const LinkElement: React.FC<LinkElementProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useResolvedLinks = ({ link, dashboardId }: Props, opened: number): ResolvedLinkDTO[] => {
|
const useResolvedLinks = ({ link, dashboardUID }: Props, opened: number): ResolvedLinkDTO[] => {
|
||||||
const { tags } = link;
|
const { tags } = link;
|
||||||
const result = useAsync(() => searchForTags(tags), [tags, opened]);
|
const result = useAsync(() => searchForTags(tags), [tags, opened]);
|
||||||
if (!result.value) {
|
if (!result.value) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return resolveLinks(dashboardId, link, result.value);
|
return resolveLinks(dashboardUID, link, result.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ResolvedLinkDTO {
|
interface ResolvedLinkDTO {
|
||||||
id: number;
|
uid: string;
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
@ -138,17 +138,17 @@ interface ResolvedLinkDTO {
|
|||||||
export async function searchForTags(
|
export async function searchForTags(
|
||||||
tags: string[],
|
tags: string[],
|
||||||
dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv }
|
dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv }
|
||||||
): Promise<DashboardSearchHit[]> {
|
): Promise<DashboardSearchItem[]> {
|
||||||
const limit = 100;
|
const limit = 100;
|
||||||
const searchHits: DashboardSearchHit[] = await dependencies.getBackendSrv().search({ tag: tags, limit });
|
const searchHits: DashboardSearchItem[] = await dependencies.getBackendSrv().search({ tag: tags, limit });
|
||||||
|
|
||||||
return searchHits;
|
return searchHits;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLinks(
|
export function resolveLinks(
|
||||||
dashboardId: number,
|
dashboardUID: string,
|
||||||
link: DashboardLink,
|
link: DashboardLink,
|
||||||
searchHits: DashboardSearchHit[],
|
searchHits: DashboardSearchItem[],
|
||||||
dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = {
|
dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = {
|
||||||
getLinkSrv,
|
getLinkSrv,
|
||||||
sanitize,
|
sanitize,
|
||||||
@ -156,14 +156,14 @@ export function resolveLinks(
|
|||||||
}
|
}
|
||||||
): ResolvedLinkDTO[] {
|
): ResolvedLinkDTO[] {
|
||||||
return searchHits
|
return searchHits
|
||||||
.filter((searchHit) => searchHit.id !== dashboardId)
|
.filter((searchHit) => searchHit.uid !== dashboardUID)
|
||||||
.map((searchHit) => {
|
.map((searchHit) => {
|
||||||
const id = searchHit.id;
|
const uid = searchHit.uid;
|
||||||
const title = dependencies.sanitize(searchHit.title);
|
const title = dependencies.sanitize(searchHit.title);
|
||||||
const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url });
|
const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url });
|
||||||
const url = dependencies.sanitizeUrl(resolvedLink);
|
const url = dependencies.sanitizeUrl(resolvedLink);
|
||||||
|
|
||||||
return { id, title, url };
|
return { uid, title, url };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ export class PublicDashboardDataSource extends DataSourceApi<any> {
|
|||||||
uid: PublicDashboardDataSource.resolveUid(datasource),
|
uid: PublicDashboardDataSource.resolveUid(datasource),
|
||||||
jsonData: {},
|
jsonData: {},
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
|
readOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.interval = '1min';
|
this.interval = '1min';
|
||||||
|
@ -199,7 +199,6 @@ describe('AddToDashboardButton', () => {
|
|||||||
});
|
});
|
||||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
|
||||||
uid: 'someUid',
|
uid: 'someUid',
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
items: [],
|
items: [],
|
||||||
@ -242,7 +241,6 @@ describe('AddToDashboardButton', () => {
|
|||||||
});
|
});
|
||||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
|
||||||
uid: 'someUid',
|
uid: 'someUid',
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
items: [],
|
items: [],
|
||||||
@ -359,7 +357,6 @@ describe('AddToDashboardButton', () => {
|
|||||||
jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
|
jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
|
||||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 1,
|
|
||||||
uid: 'someUid',
|
uid: 'someUid',
|
||||||
isStarred: false,
|
isStarred: false,
|
||||||
items: [],
|
items: [],
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user