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": [
|
||||
[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": [
|
||||
[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.", "10"],
|
||||
[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.", "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"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"]
|
||||
],
|
||||
"packages/grafana-ui/src/components/Select/SelectMenu.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
@ -101,7 +101,7 @@
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@lingui/cli": "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",
|
||||
"@react-types/button": "3.5.1",
|
||||
"@react-types/menu": "3.6.1",
|
||||
@ -114,7 +114,7 @@
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@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-route": "1.7.2",
|
||||
"@types/classnames": "2.3.0",
|
||||
@ -189,7 +189,7 @@
|
||||
"eslint-plugin-jest": "26.6.0",
|
||||
"eslint-plugin-jsdoc": "39.3.3",
|
||||
"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-webpack-plugin": "3.2.0",
|
||||
"expose-loader": "4.0.0",
|
||||
@ -404,7 +404,7 @@
|
||||
"resolutions": {
|
||||
"underscore": "1.13.4",
|
||||
"@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/rig-package": "0.3.13",
|
||||
"@rushstack/ts-command-line": "4.12.1",
|
||||
|
@ -64,7 +64,7 @@
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@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/jest": "28.1.6",
|
||||
"@types/jquery": "3.5.14",
|
||||
|
@ -574,6 +574,7 @@ export interface DataSourceInstanceSettings<T extends DataSourceJsonData = DataS
|
||||
type: string;
|
||||
name: string;
|
||||
meta: DataSourcePluginMeta;
|
||||
readOnly: boolean;
|
||||
url?: string;
|
||||
jsonData: T;
|
||||
username?: string;
|
||||
|
@ -52,7 +52,7 @@
|
||||
"@rollup/plugin-node-resolve": "13.3.0",
|
||||
"@testing-library/dom": "8.13.0",
|
||||
"@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/history": "4.7.11",
|
||||
"@types/jest": "28.1.6",
|
||||
|
@ -46,6 +46,7 @@ export interface DataSourcePickerProps {
|
||||
inputId?: string;
|
||||
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
|
||||
onClear?: () => void;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,7 +187,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
||||
placeholder={placeholder}
|
||||
noOptionsMessage="No datasources found"
|
||||
value={value ?? null}
|
||||
invalid={!!error}
|
||||
invalid={Boolean(error) || Boolean(this.props.invalid)}
|
||||
getOptionLabel={(o) => {
|
||||
if (o.meta && isUnsignedPluginSignature(o.meta.signature) && o !== value) {
|
||||
return (
|
||||
|
@ -130,7 +130,7 @@
|
||||
"@testing-library/jest-dom": "5.16.4",
|
||||
"@testing-library/react": "12.1.4",
|
||||
"@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/common-tags": "^1.8.0",
|
||||
"@types/d3": "7.4.0",
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
|
||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
||||
import React from 'react';
|
||||
|
||||
import { Cascader, CascaderOption, CascaderProps } from './Cascader';
|
||||
@ -47,7 +46,7 @@ describe('Cascader', () => {
|
||||
const placeholder = 'cascader-placeholder';
|
||||
|
||||
describe('options from state change', () => {
|
||||
let user: UserEvent;
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
@ -13,9 +13,11 @@ export interface Props {
|
||||
/** Disable button click action */
|
||||
disabled?: boolean;
|
||||
'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 (
|
||||
<ConfirmButton
|
||||
confirmText="Delete"
|
||||
@ -23,6 +25,7 @@ export const DeleteButton: FC<Props> = ({ size, disabled, onConfirm, 'aria-label
|
||||
size={size || 'md'}
|
||||
disabled={disabled}
|
||||
onConfirm={onConfirm}
|
||||
closeOnConfirm={closeOnConfirm}
|
||||
>
|
||||
<Button aria-label={ariaLabel} variant="destructive" icon="times" size={size || 'sm'} />
|
||||
</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> {
|
||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||
value?: SelectableValue<T> | null;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
|
||||
|
@ -317,28 +317,28 @@ export function SelectBase<T>({
|
||||
/>
|
||||
);
|
||||
},
|
||||
LoadingIndicator(props: any) {
|
||||
return <Spinner inline={true} />;
|
||||
LoadingIndicator() {
|
||||
return <Spinner inline />;
|
||||
},
|
||||
LoadingMessage(props: any) {
|
||||
LoadingMessage() {
|
||||
return <div className={styles.loadingMessage}>{loadingMessage}</div>;
|
||||
},
|
||||
NoOptionsMessage(props: any) {
|
||||
NoOptionsMessage() {
|
||||
return (
|
||||
<div className={styles.loadingMessage} aria-label="No options provided">
|
||||
{noOptionsMessage}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
DropdownIndicator(props: any) {
|
||||
DropdownIndicator(props) {
|
||||
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />;
|
||||
},
|
||||
SingleValue(props: any) {
|
||||
return <SingleValue {...props} disabled={disabled} />;
|
||||
},
|
||||
SelectContainer,
|
||||
MultiValueContainer: MultiValueContainer,
|
||||
MultiValueRemove: MultiValueRemove,
|
||||
SelectContainer,
|
||||
...components,
|
||||
}}
|
||||
styles={selectStyles}
|
||||
|
@ -10,19 +10,24 @@ import { focusCss } from '../../themes/mixins';
|
||||
import { sharedInputStyle } from '../Forms/commonStyles';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
|
||||
// isFocus prop is actually available, but its not in the types for the version we have.
|
||||
export interface SelectContainerProps<Option, isMulti extends boolean, Group extends GroupBase<Option>>
|
||||
extends BaseContainerProps<Option, isMulti, Group> {
|
||||
isFocused: boolean;
|
||||
}
|
||||
import { CustomComponentProps } from './types';
|
||||
|
||||
// prettier-ignore
|
||||
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>>(
|
||||
props: SelectContainerProps<Option, isMulti, Group>
|
||||
) => {
|
||||
const { isDisabled, isFocused, children } = props;
|
||||
const {
|
||||
isDisabled,
|
||||
isFocused,
|
||||
children,
|
||||
selectProps: { invalid = false },
|
||||
} = props;
|
||||
|
||||
const theme = useTheme2();
|
||||
const styles = getSelectContainerStyles(theme, isFocused, isDisabled);
|
||||
const styles = getSelectContainerStyles(theme, isFocused, isDisabled, invalid);
|
||||
|
||||
return (
|
||||
<components.SelectContainer {...props} className={cx(styles.wrapper, props.className)}>
|
||||
@ -31,13 +36,14 @@ export const SelectContainer = <Option, isMulti extends boolean, Group extends G
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectContainerStyles = stylesFactory((theme: GrafanaTheme2, focused: boolean, disabled: boolean) => {
|
||||
const styles = getInputStyles({ theme, invalid: false });
|
||||
const getSelectContainerStyles = stylesFactory(
|
||||
(theme: GrafanaTheme2, focused: boolean, disabled: boolean, invalid: boolean) => {
|
||||
const styles = getInputStyles({ theme, invalid });
|
||||
|
||||
return {
|
||||
wrapper: cx(
|
||||
styles.wrapper,
|
||||
sharedInputStyle(theme, false),
|
||||
sharedInputStyle(theme, invalid),
|
||||
focused &&
|
||||
css`
|
||||
${focusCss(theme.v1)}
|
||||
@ -62,4 +68,5 @@ const getSelectContainerStyles = stylesFactory((theme: GrafanaTheme2, focused: b
|
||||
`
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@ -1,5 +1,10 @@
|
||||
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';
|
||||
|
||||
@ -103,10 +108,13 @@ export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'o
|
||||
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> {
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
// This is used for the `renderControl` prop on *our* SelectBase component
|
||||
export interface CustomControlProps<T> {
|
||||
ref: React.Ref<any>;
|
||||
isOpen: boolean;
|
||||
@ -133,3 +141,20 @@ export type SelectOptions<T = any> =
|
||||
| Array<SelectableValue<T> | SelectableOptGroup<T> | Array<SelectableOptGroup<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 userEvent from '@testing-library/user-event';
|
||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
||||
import React from 'react';
|
||||
|
||||
import { Slider } from './Slider';
|
||||
@ -12,7 +11,7 @@ const sliderProps: SliderProps = {
|
||||
};
|
||||
|
||||
describe('Slider', () => {
|
||||
let user: UserEvent;
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
|
@ -93,6 +93,7 @@ export const getAvailableIcons = () =>
|
||||
'gf-bar-alignment-after',
|
||||
'gf-bar-alignment-before',
|
||||
'gf-bar-alignment-center',
|
||||
'gf-glue',
|
||||
'gf-grid',
|
||||
'gf-interpolation-linear',
|
||||
'gf-interpolation-smooth',
|
||||
|
@ -12,7 +12,7 @@
|
||||
"@grafana/tsconfig": "^1.2.0-rc1",
|
||||
"@testing-library/jest-dom": "5.16.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/deep-freeze": "^0.1.1",
|
||||
"@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;
|
||||
}
|
||||
|
||||
getColor = (key: string) => getRgbColorByKey(key, this.props.theme);
|
||||
getColor = (key: string) => getRgbColorByKey(key);
|
||||
|
||||
componentDidMount() {
|
||||
this._draw();
|
||||
|
@ -392,7 +392,6 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
hoverIndentGuideIds,
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
theme,
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
focusedSpanIdForSearch,
|
||||
@ -401,7 +400,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
if (!trace) {
|
||||
return null;
|
||||
}
|
||||
const color = getColorByKey(serviceName, theme);
|
||||
const color = getColorByKey(serviceName);
|
||||
const isCollapsed = childrenHiddenIDs.has(spanID);
|
||||
const isDetailExpanded = detailStates.has(spanID);
|
||||
const isMatchingFilter = findMatchesIDs ? findMatchesIDs.has(spanID) : false;
|
||||
@ -415,7 +414,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
if (rpcSpan) {
|
||||
const rpcViewBounds = this.getViewedBounds()(rpcSpan.startTime, rpcSpan.startTime + rpcSpan.duration);
|
||||
rpc = {
|
||||
color: getColorByKey(rpcSpan.process.serviceName, theme),
|
||||
color: getColorByKey(rpcSpan.process.serviceName),
|
||||
operationName: rpcSpan.operationName,
|
||||
serviceName: rpcSpan.process.serviceName,
|
||||
viewEnd: rpcViewBounds.end,
|
||||
@ -431,7 +430,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
if (!span.hasChildren && peerServiceKV && isKindClient(span)) {
|
||||
noInstrumentedServer = {
|
||||
serviceName: peerServiceKV.value,
|
||||
color: getColorByKey(peerServiceKV.value, theme),
|
||||
color: getColorByKey(peerServiceKV.value),
|
||||
};
|
||||
}
|
||||
|
||||
@ -487,7 +486,6 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
addHoverIndentGuideId,
|
||||
removeHoverIndentGuideId,
|
||||
linksGetter,
|
||||
theme,
|
||||
createSpanLink,
|
||||
focusedSpanId,
|
||||
createFocusSpanLink,
|
||||
@ -497,7 +495,7 @@ export class UnthemedVirtualizedTraceView extends React.Component<VirtualizedTra
|
||||
if (!trace || !detailState) {
|
||||
return null;
|
||||
}
|
||||
const color = getColorByKey(serviceName, theme);
|
||||
const color = getColorByKey(serviceName);
|
||||
const styles = getStyles(this.props);
|
||||
return (
|
||||
<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
|
||||
// limitations under the License.
|
||||
|
||||
import { createTheme } from '@grafana/data';
|
||||
|
||||
import { getColorByKey, clear } from './color-generator';
|
||||
|
||||
it('gives the same color for the same key', () => {
|
||||
clear();
|
||||
const colorOne = getColorByKey('serviceA', createTheme());
|
||||
const colorTwo = getColorByKey('serviceA', createTheme());
|
||||
const colorOne = getColorByKey('serviceA');
|
||||
const colorTwo = getColorByKey('serviceA');
|
||||
expect(colorOne).toBe(colorTwo);
|
||||
});
|
||||
|
||||
it('gives different colors for each for each key', () => {
|
||||
clear();
|
||||
const colorOne = getColorByKey('serviceA', createTheme());
|
||||
const colorTwo = getColorByKey('serviceB', createTheme());
|
||||
const colorOne = getColorByKey('serviceA');
|
||||
const colorTwo = getColorByKey('serviceB');
|
||||
expect(colorOne).not.toBe(colorTwo);
|
||||
});
|
||||
|
||||
@ -34,6 +32,6 @@ it('should not allow red', () => {
|
||||
clear();
|
||||
// when aPAKNMeFcF is hashed it's index is 4
|
||||
// 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');
|
||||
});
|
||||
|
@ -14,7 +14,6 @@
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { colors } from '@grafana/ui';
|
||||
|
||||
// TS needs the precise return type
|
||||
@ -95,10 +94,10 @@ export function clear() {
|
||||
getGenerator([]);
|
||||
}
|
||||
|
||||
export function getColorByKey(key: string, theme: GrafanaTheme2) {
|
||||
export function getColorByKey(key: string) {
|
||||
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);
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ var (
|
||||
// that HTTPServer needs
|
||||
func (hs *HTTPServer) declareFixedRoles() error {
|
||||
// Declare plugins roles
|
||||
if err := plugins.DeclareRBACRoles(hs.AccessControl); err != nil {
|
||||
if err := plugins.DeclareRBACRoles(hs.accesscontrolService); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -419,7 +419,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
return hs.AccessControl.DeclareFixedRoles(
|
||||
return hs.accesscontrolService.DeclareFixedRoles(
|
||||
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
||||
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
|
||||
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
|
||||
|
@ -40,6 +40,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
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/datasources"
|
||||
"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("/datasources/", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), 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/new", reqOrgAdmin, 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,
|
||||
IsDefault: ds.IsDefault,
|
||||
Access: string(ds.Access),
|
||||
ReadOnly: ds.ReadOnly,
|
||||
}
|
||||
|
||||
plugin, exists := enabledPlugins.Get(plugins.DataSource, ds.Type)
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
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/datasources"
|
||||
"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)) {
|
||||
configNodes = append(configNodes, &dtos.NavLink{
|
||||
Text: "Users",
|
||||
|
@ -13,7 +13,7 @@ var (
|
||||
ScopeProvider = ac.NewScopeProvider("plugins")
|
||||
)
|
||||
|
||||
func DeclareRBACRoles(acService ac.AccessControl) error {
|
||||
func DeclareRBACRoles(service ac.Service) error {
|
||||
AppPluginsReader := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: ac.FixedRolePrefix + "plugins.app:reader",
|
||||
@ -26,5 +26,5 @@ func DeclareRBACRoles(acService ac.AccessControl) error {
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}
|
||||
return acService.DeclareFixedRoles(AppPluginsReader)
|
||||
return service.DeclareFixedRoles(AppPluginsReader)
|
||||
}
|
||||
|
@ -220,6 +220,7 @@ type DataSourceDTO struct {
|
||||
Preload bool `json:"preload"`
|
||||
Module string `json:"module,omitempty"`
|
||||
JSONData map[string]interface{} `json:"jsonData"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
|
||||
BasicAuth string `json:"basicAuth,omitempty"`
|
||||
WithCredentials bool `json:"withCredentials,omitempty"`
|
||||
|
@ -18,10 +18,6 @@ type AccessControl interface {
|
||||
// RegisterScopeAttributeResolver allows the caller to register a scope resolver for a
|
||||
// specific scope prefix (ex: datasources:name:)
|
||||
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() 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) DeclareFixedRoles(registrations ...accesscontrol.RoleRegistration) error {
|
||||
return f.ExpectedErr
|
||||
}
|
||||
|
||||
func (f FakeAccessControl) IsDisabled() bool {
|
||||
return f.ExpectedDisabled
|
||||
}
|
||||
|
@ -66,11 +66,6 @@ func (a *AccessControl) RegisterScopeAttributeResolver(prefix string, resolver a
|
||||
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 {
|
||||
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 {
|
||||
return ac.DeclareFixedRoles(
|
||||
func DeclareFixedRoles(service accesscontrol.Service) error {
|
||||
return service.DeclareFixedRoles(
|
||||
rulesReaderRole, rulesWriterRole,
|
||||
instancesReaderRole, instancesWriterRole,
|
||||
notificationsReaderRole, notificationsWriterRole,
|
||||
|
@ -22,7 +22,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@ -76,7 +75,6 @@ type API struct {
|
||||
DataProxy *datasourceproxy.DataSourceProxyService
|
||||
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
||||
StateManager *state.Manager
|
||||
SecretsService secrets.Service
|
||||
AccessControl accesscontrol.AccessControl
|
||||
Policies *provisioning.NotificationPolicyService
|
||||
ContactPointService *provisioning.ContactPointService
|
||||
@ -128,7 +126,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
DatasourceCache: api.DatasourceCache,
|
||||
log: logger,
|
||||
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)
|
||||
api.RegisterConfigurationApiEndpoints(NewConfiguration(
|
||||
&ConfigSrv{
|
||||
|
@ -11,14 +11,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/expr/classic"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"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/setting"
|
||||
|
||||
@ -38,7 +36,6 @@ type evaluatorImpl struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
dataSourceCache datasources.CacheService
|
||||
secretsService secrets.Service
|
||||
expressionService *expr.Service
|
||||
}
|
||||
|
||||
@ -46,13 +43,11 @@ func NewEvaluator(
|
||||
cfg *setting.Cfg,
|
||||
log log.Logger,
|
||||
datasourceCache datasources.CacheService,
|
||||
secretsService secrets.Service,
|
||||
expressionService *expr.Service) Evaluator {
|
||||
return &evaluatorImpl{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
dataSourceCache: datasourceCache,
|
||||
secretsService: secretsService,
|
||||
expressionService: expressionService,
|
||||
}
|
||||
}
|
||||
@ -164,7 +159,7 @@ type AlertExecCtx struct {
|
||||
}
|
||||
|
||||
// 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{
|
||||
OrgId: ctx.OrgID,
|
||||
Headers: map[string]string{
|
||||
@ -207,19 +202,6 @@ func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, d
|
||||
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{
|
||||
TimeRange: expr.TimeRange{
|
||||
From: q.RelativeTimeRange.ToTimeRange(now).From,
|
||||
@ -236,32 +218,6 @@ func getExprRequest(ctx AlertExecCtx, data []models.AlertQuery, now time.Time, d
|
||||
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 {
|
||||
Var string // RefID
|
||||
Labels data.Labels
|
||||
@ -347,7 +303,7 @@ func queryDataResponseToExecutionResults(c models.Condition, execResp *backend.Q
|
||||
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() {
|
||||
if e := recover(); e != nil {
|
||||
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 {
|
||||
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}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to execute conditions: %w", err)
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
|
||||
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
|
||||
quotaService quota.Service, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert,
|
||||
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{
|
||||
Cfg: cfg,
|
||||
DataSourceCache: dataSourceCache,
|
||||
@ -61,6 +61,7 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
|
||||
dashboardService: dashboardService,
|
||||
renderService: renderService,
|
||||
bus: bus,
|
||||
accesscontrolService: accesscontrolService,
|
||||
}
|
||||
|
||||
if ng.IsDisabled() {
|
||||
@ -100,6 +101,7 @@ type AlertNG struct {
|
||||
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
||||
AlertsRouter *sender.AlertsRouter
|
||||
accesscontrol accesscontrol.AccessControl
|
||||
accesscontrolService accesscontrol.Service
|
||||
|
||||
bus bus.Bus
|
||||
}
|
||||
@ -156,7 +158,7 @@ func (ng *AlertNG) init() error {
|
||||
Cfg: ng.Cfg.UnifiedAlerting,
|
||||
C: clk,
|
||||
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,
|
||||
RuleStore: store,
|
||||
Metrics: ng.Metrics.GetSchedulerMetrics(),
|
||||
@ -192,7 +194,6 @@ func (ng *AlertNG) init() error {
|
||||
Schedule: ng.schedule,
|
||||
DataProxy: ng.DataProxy,
|
||||
QuotaService: ng.QuotaService,
|
||||
SecretsService: ng.SecretsService,
|
||||
TransactionManager: store,
|
||||
InstanceStore: store,
|
||||
RuleStore: store,
|
||||
@ -211,7 +212,7 @@ func (ng *AlertNG) init() error {
|
||||
}
|
||||
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) {
|
||||
|
@ -30,8 +30,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"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/util"
|
||||
)
|
||||
@ -501,8 +499,7 @@ func setupScheduler(t *testing.T, rs *store.FakeRuleStore, is *store.FakeInstanc
|
||||
|
||||
var evaluator eval.Evaluator = evalMock
|
||||
if evalMock == nil {
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
evaluator = eval.NewEvaluator(&setting.Cfg{ExpressionsEnabled: true}, logger, nil, secretsService, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil))
|
||||
evaluator = eval.NewEvaluator(&setting.Cfg{ExpressionsEnabled: true}, logger, nil, expr.ProvideService(&setting.Cfg{ExpressionsEnabled: true}, nil, nil))
|
||||
}
|
||||
|
||||
if registry == nil {
|
||||
|
@ -64,7 +64,7 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, *
|
||||
|
||||
ng, err := ngalert.ProvideService(
|
||||
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)
|
||||
return ng, &store.DBstore{
|
||||
|
@ -54,6 +54,7 @@ func ProvideApi(
|
||||
return api
|
||||
}
|
||||
|
||||
//Registers Endpoints on Grafana Router
|
||||
func (api *Api) RegisterAPIEndpoints() {
|
||||
auth := accesscontrol.Middleware(api.AccessControl)
|
||||
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))
|
||||
}
|
||||
|
||||
// gets public dashboard
|
||||
// Gets public dashboard
|
||||
// GET /api/public/dashboards/:accessToken
|
||||
func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
||||
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 {
|
||||
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{
|
||||
Slug: dash.Slug,
|
||||
Type: models.DashTypeDB,
|
||||
@ -98,7 +99,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
||||
IsFolder: false,
|
||||
FolderId: dash.FolderId,
|
||||
PublicDashboardAccessToken: accessToken,
|
||||
PublicDashboardUID: pubDash.Uid,
|
||||
PublicDashboardUID: pubdash.Uid,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
pdc, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgID, web.Params(c.Req)[":uid"])
|
||||
if err != nil {
|
||||
@ -115,7 +117,8 @@ func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response
|
||||
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 {
|
||||
pubdash := &PublicDashboard{}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return response.Error(http.StatusInternalServerError, "could not fetch dashboard", err)
|
||||
}
|
||||
|
||||
publicDashboard, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dashboard.OrgId, dashboard.Uid)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "could not fetch public dashboard", err)
|
||||
}
|
||||
|
||||
// Build the request data objecct
|
||||
reqDTO, err := api.PublicDashboardService.BuildPublicDashboardMetricRequest(
|
||||
c.Req.Context(),
|
||||
dashboard,
|
||||
publicDashboard,
|
||||
pubdash,
|
||||
panelId,
|
||||
)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
|
@ -43,10 +43,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
qs := buildQueryDataService(t, nil, nil, nil)
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(&models.Dashboard{}, nil).Maybe()
|
||||
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
||||
Return(&PublicDashboard{}, nil).Maybe()
|
||||
|
||||
Return(&PublicDashboard{}, &models.Dashboard{}, nil).Maybe()
|
||||
testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil)
|
||||
|
||||
response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t)
|
||||
@ -70,26 +67,26 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
Name string
|
||||
AccessToken string
|
||||
ExpectedHttpResponse int
|
||||
PublicDashboardResult *models.Dashboard
|
||||
PublicDashboardErr error
|
||||
DashboardResult *models.Dashboard
|
||||
Err error
|
||||
}{
|
||||
{
|
||||
Name: "It gets a public dashboard",
|
||||
AccessToken: accessToken,
|
||||
ExpectedHttpResponse: http.StatusOK,
|
||||
PublicDashboardResult: &models.Dashboard{
|
||||
DashboardResult: &models.Dashboard{
|
||||
Data: simplejson.NewFromAny(map[string]interface{}{
|
||||
"Uid": DashboardUid,
|
||||
}),
|
||||
},
|
||||
PublicDashboardErr: nil,
|
||||
Err: nil,
|
||||
},
|
||||
{
|
||||
Name: "It should return 404 if no public dashboard",
|
||||
AccessToken: accessToken,
|
||||
ExpectedHttpResponse: http.StatusNotFound,
|
||||
PublicDashboardResult: nil,
|
||||
PublicDashboardErr: ErrPublicDashboardNotFound,
|
||||
DashboardResult: nil,
|
||||
Err: ErrPublicDashboardNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
@ -97,9 +94,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe()
|
||||
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
||||
Return(&PublicDashboard{}, nil).Maybe()
|
||||
Return(&PublicDashboard{}, test.DashboardResult, test.Err).Maybe()
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
cfg.RBACEnabled = false
|
||||
@ -121,7 +116,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
|
||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||
|
||||
if test.PublicDashboardErr == nil {
|
||||
if test.Err == nil {
|
||||
var dashResp dtos.DashboardFullWithMeta
|
||||
err := json.Unmarshal(response.Body.Bytes(), &dashResp)
|
||||
require.NoError(t, err)
|
||||
@ -136,7 +131,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
}
|
||||
err := json.Unmarshal(response.Body.Bytes(), &errResp)
|
||||
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) {
|
||||
server, fakeDashboardService := setup(true)
|
||||
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, 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{
|
||||
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) {
|
||||
server, fakeDashboardService := setup(true)
|
||||
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, 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{
|
||||
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) {
|
||||
server, fakeDashboardService := setup(true)
|
||||
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil)
|
||||
fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&PublicDashboard{}, &models.Dashboard{}, 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{
|
||||
Queries: []*simplejson.Json{
|
||||
|
@ -9,11 +9,12 @@ import (
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
models "github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
|
||||
publicdashboardsmodels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
|
||||
testing "testing"
|
||||
|
||||
user "github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
// 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
|
||||
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)
|
||||
|
||||
var r0 *models.Dashboard
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *models.Dashboard); ok {
|
||||
var r0 *publicdashboardsmodels.PublicDashboard
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) *publicdashboardsmodels.PublicDashboard); ok {
|
||||
r0 = rf(ctx, accessToken)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.Dashboard)
|
||||
r0 = ret.Get(0).(*publicdashboardsmodels.PublicDashboard)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
var r1 *models.Dashboard
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) *models.Dashboard); ok {
|
||||
r1 = rf(ctx, accessToken)
|
||||
} 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
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
//go:generate mockery --name Service --structname FakePublicDashboardService --inpackage --filename public_dashboard_service_mock.go
|
||||
type Service interface {
|
||||
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)
|
||||
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*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
|
||||
func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) {
|
||||
pubdash, d, err := pd.store.GetPublicDashboard(ctx, accessToken)
|
||||
func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) {
|
||||
pubdash, dash, err := pd.store.GetPublicDashboard(ctx, accessToken)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if pubdash == nil || d == nil {
|
||||
return nil, ErrPublicDashboardNotFound
|
||||
if pubdash == nil || dash == nil {
|
||||
return nil, nil, ErrPublicDashboardNotFound
|
||||
}
|
||||
|
||||
if !pubdash.IsEnabled {
|
||||
return nil, ErrPublicDashboardNotFound
|
||||
return nil, nil, ErrPublicDashboardNotFound
|
||||
}
|
||||
|
||||
ts := pubdash.BuildTimeSettings(d)
|
||||
d.Data.SetPath([]string{"time", "from"}, ts.From)
|
||||
d.Data.SetPath([]string{"time", "to"}, ts.To)
|
||||
|
||||
return d, nil
|
||||
return pubdash, dash, nil
|
||||
}
|
||||
|
||||
// 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 defaultPubdashTimeSettings, _ = simplejson.NewJson([]byte(`{}`))
|
||||
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) {
|
||||
assert.Equal(t, LogPrefix, "publicdashboards.service")
|
||||
@ -49,29 +48,18 @@ func TestGetPublicDashboard(t *testing.T) {
|
||||
Name: "returns a dashboard",
|
||||
AccessToken: "abc123",
|
||||
StoreResp: &storeResp{
|
||||
pd: &PublicDashboard{IsEnabled: true},
|
||||
pd: &PublicDashboard{AccessToken: "abcdToken", IsEnabled: true},
|
||||
d: &models.Dashboard{Uid: "mydashboard", Data: dashboardData},
|
||||
err: nil,
|
||||
},
|
||||
ErrResp: nil,
|
||||
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",
|
||||
AccessToken: "abc123",
|
||||
StoreResp: &storeResp{
|
||||
pd: &PublicDashboard{IsEnabled: false},
|
||||
pd: &PublicDashboard{AccessToken: "abcdToken", IsEnabled: false},
|
||||
d: &models.Dashboard{Uid: "mydashboard"},
|
||||
err: nil,
|
||||
},
|
||||
@ -105,17 +93,18 @@ func TestGetPublicDashboard(t *testing.T) {
|
||||
fakeStore.On("GetPublicDashboard", mock.Anything, mock.Anything).
|
||||
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 {
|
||||
assert.Error(t, test.ErrResp, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, test.DashResp, dashboard)
|
||||
assert.Equal(t, test.DashResp, dash)
|
||||
|
||||
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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
@ -27,11 +26,6 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
)
|
||||
|
||||
const (
|
||||
headerName = "httpHeaderName"
|
||||
headerValue = "httpHeaderValue"
|
||||
)
|
||||
|
||||
func ProvideService(
|
||||
cfg *setting.Cfg,
|
||||
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 {
|
||||
proxyutil.ClearCookieHeader(parsedReq.httpRequest, ds.AllowedCookies())
|
||||
if cookieStr := parsedReq.httpRequest.Header.Get("Cookie"); cookieStr != "" {
|
||||
@ -216,26 +206,6 @@ type parsedRequest struct {
|
||||
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) {
|
||||
if len(reqDTO.Queries) == 0 {
|
||||
return nil, NewErrBadQuery("no queries found")
|
||||
|
@ -2,7 +2,6 @@ package query_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@ -27,22 +26,6 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
token := &oauth2.Token{
|
||||
TokenType: "bearer",
|
||||
|
@ -84,6 +84,9 @@ type searchIndex struct {
|
||||
mu sync.RWMutex
|
||||
loader dashboardLoader
|
||||
perOrgIndex map[int64]*orgIndex
|
||||
initializedOrgs map[int64]bool
|
||||
initialIndexingComplete bool
|
||||
initializationMutex sync.RWMutex
|
||||
eventStore eventStore
|
||||
logger log.Logger
|
||||
buildSignals chan buildSignal
|
||||
@ -97,6 +100,7 @@ func newSearchIndex(dashLoader dashboardLoader, evStore eventStore, extender Doc
|
||||
loader: dashLoader,
|
||||
eventStore: evStore,
|
||||
perOrgIndex: map[int64]*orgIndex{},
|
||||
initializedOrgs: map[int64]bool{},
|
||||
logger: log.New("searchIndex"),
|
||||
buildSignals: make(chan buildSignal),
|
||||
extender: extender,
|
||||
@ -105,6 +109,50 @@ func newSearchIndex(dashLoader dashboardLoader, evStore eventStore, extender Doc
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
doneCh := make(chan struct{}, 1)
|
||||
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.
|
||||
reIndexDoneCh := make(chan int64, 1)
|
||||
|
||||
i.initializationMutex.Lock()
|
||||
i.initialIndexingComplete = true
|
||||
i.initializationMutex.Unlock()
|
||||
|
||||
for {
|
||||
select {
|
||||
case doneCh := <-i.syncCh:
|
||||
@ -421,6 +473,10 @@ func (i *searchIndex) buildOrgIndex(ctx context.Context, orgID int64) (int, erro
|
||||
i.perOrgIndex[orgID] = index
|
||||
i.mu.Unlock()
|
||||
|
||||
i.initializationMutex.Lock()
|
||||
i.initializedOrgs[orgID] = true
|
||||
i.initializationMutex.Unlock()
|
||||
|
||||
if orgID == 1 {
|
||||
go func() {
|
||||
if reader, cancel, err := index.readerForIndex(indexTypeDashboard); err == nil {
|
||||
|
@ -45,6 +45,20 @@ func (_m *MockSearchService) IsDisabled() bool {
|
||||
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
|
||||
func (_m *MockSearchService) RegisterDashboardIndexExtender(ext DashboardIndexExtender) {
|
||||
_m.Called(ext)
|
||||
|
@ -64,6 +64,10 @@ type StandardSearchService 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 {
|
||||
extender := &NoopExtender{}
|
||||
s := &StandardSearchService{
|
||||
|
@ -10,6 +10,10 @@ import (
|
||||
type stubSearchService struct {
|
||||
}
|
||||
|
||||
func (s *stubSearchService) IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse {
|
||||
return IsSearchReadyResponse{}
|
||||
}
|
||||
|
||||
func (s *stubSearchService) IsDisabled() bool {
|
||||
return true
|
||||
}
|
||||
|
@ -31,11 +31,17 @@ type DashboardQuery struct {
|
||||
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
|
||||
type SearchService interface {
|
||||
registry.CanBeDisabled
|
||||
registry.BackgroundService
|
||||
DoDashboardQuery(ctx context.Context, user *backend.User, orgId int64, query DashboardQuery) *backend.DataResponse
|
||||
IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse
|
||||
RegisterDashboardIndexExtender(ext DashboardIndexExtender)
|
||||
TriggerReIndex()
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
)
|
||||
|
||||
func RegisterRoles(ac accesscontrol.AccessControl) error {
|
||||
func RegisterRoles(service accesscontrol.Service) error {
|
||||
saReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:serviceaccounts:reader",
|
||||
@ -69,7 +69,7 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,7 @@ func ProvideServiceAccountsService(
|
||||
usageStats usagestats.Service,
|
||||
serviceAccountsStore serviceaccounts.Store,
|
||||
permissionService accesscontrol.ServiceAccountPermissionsService,
|
||||
accesscontrolService accesscontrol.Service,
|
||||
) (*ServiceAccountsService, error) {
|
||||
s := &ServiceAccountsService{
|
||||
store: serviceAccountsStore,
|
||||
@ -38,7 +39,7 @@ func ProvideServiceAccountsService(
|
||||
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)
|
||||
}
|
||||
|
||||
|
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)
|
||||
|
||||
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 {
|
||||
|
@ -325,7 +325,7 @@ func TestExecuteTimeSeriesQuery(t *testing.T) {
|
||||
require.Equal(t, firstLevel.Aggregation.Type, "date_histogram")
|
||||
hAgg := firstLevel.Aggregation.Aggregation.(*es.DateHistogramAgg)
|
||||
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)
|
||||
|
||||
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/data"
|
||||
|
||||
"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/searchV2"
|
||||
"github.com/grafana/grafana/pkg/services/store"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"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
|
||||
@ -36,6 +38,17 @@ const DatasourceUID = "grafana"
|
||||
var (
|
||||
_ backend.QueryDataHandler = (*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 {
|
||||
@ -46,6 +59,7 @@ func newService(cfg *setting.Cfg, search searchV2.SearchService, store store.Sto
|
||||
s := &Service{
|
||||
search: search,
|
||||
store: store,
|
||||
log: log.New("grafanads"),
|
||||
}
|
||||
|
||||
return s
|
||||
@ -55,6 +69,7 @@ func newService(cfg *setting.Cfg, search searchV2.SearchService, store store.Sto
|
||||
type Service struct {
|
||||
search searchV2.SearchService
|
||||
store store.StorageService
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
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 {
|
||||
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{}
|
||||
err := json.Unmarshal(query.JSON, &m)
|
||||
if err != nil {
|
||||
|
@ -3,12 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/adapters"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
@ -16,11 +14,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
const (
|
||||
headerName = "httpHeaderName"
|
||||
headerValue = "httpHeaderValue"
|
||||
)
|
||||
|
||||
var oAuthIsOAuthPassThruEnabledFunc = func(oAuthTokenService oauthtoken.OAuthTokenService, ds *datasources.DataSource) bool {
|
||||
return oAuthTokenService.IsOAuthPassThruEnabled(ds)
|
||||
}
|
||||
@ -126,11 +119,6 @@ func generateRequest(ctx context.Context, ds *datasources.DataSource, decryptedJ
|
||||
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 {
|
||||
modelJSON, err := q.Model.MarshalJSON()
|
||||
if err != nil {
|
||||
@ -151,24 +139,4 @@ func generateRequest(ctx context.Context, ds *datasources.DataSource, decryptedJ
|
||||
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{}
|
||||
|
@ -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 {
|
||||
plugins.Client
|
||||
backend.QueryDataHandlerFunc
|
||||
|
@ -20,6 +20,7 @@ describe('InputDatasource', () => {
|
||||
name: 'xxx',
|
||||
meta: {} as PluginMeta,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
jsonData: {
|
||||
data,
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { AsyncMultiSelect, Icon, Button, useStyles2 } from '@grafana/ui';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { FolderInfo, PermissionLevelString } from 'app/types';
|
||||
|
||||
export interface FolderFilterProps {
|
||||
@ -75,7 +76,9 @@ async function getFoldersAsOptions(searchString: string, setLoading: (loading: b
|
||||
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 } }));
|
||||
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
|
||||
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' }),
|
||||
datasources: defineMessage({ id: 'nav.datasources', message: 'Data sources' }),
|
||||
correlations: defineMessage({ id: 'nav.correlations', message: 'Correlations' }),
|
||||
users: defineMessage({ id: 'nav.users', message: 'Users' }),
|
||||
teams: defineMessage({ id: 'nav.teams', message: 'Teams' }),
|
||||
plugins: defineMessage({ id: 'nav.plugins', message: 'Plugins' }),
|
||||
|
@ -4,6 +4,7 @@ import React, { FC } from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { AsyncSelect } from '@grafana/ui';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
|
||||
/**
|
||||
* @deprecated prefer using dashboard uid rather than id
|
||||
@ -70,10 +71,12 @@ async function getDashboards(
|
||||
label: string,
|
||||
excludedDashboards?: string[]
|
||||
): 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 value: DashboardPickerItem = {
|
||||
id,
|
||||
id: id!,
|
||||
uid,
|
||||
[label]: `${folderTitle ?? 'General'}/${title}`,
|
||||
};
|
||||
|
@ -74,9 +74,12 @@ export const Page: PageType = ({
|
||||
);
|
||||
};
|
||||
|
||||
const OldNavOnly = () => null;
|
||||
OldNavOnly.displayName = 'OldNavOnly';
|
||||
|
||||
Page.Header = PageHeader;
|
||||
Page.Contents = PageContents;
|
||||
Page.OldNavOnly = () => null;
|
||||
Page.OldNavOnly = OldNavOnly;
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const shadow = theme.isDark
|
||||
|
@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { AsyncSelectProps, AsyncSelect } from '@grafana/ui';
|
||||
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';
|
||||
|
||||
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 getDashboards = debounce((query = ''): Promise<Array<SelectableValue<DashboardPickerDTO>>> => {
|
||||
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchHit[]) => {
|
||||
return result.map((item: DashboardSearchHit) => ({
|
||||
return backendSrv.search({ type: 'dash-db', query, limit: 100 }).then((result: DashboardSearchItem[]) => {
|
||||
return result.map((item: DashboardSearchItem) => ({
|
||||
value: {
|
||||
// dashboards uid here is always defined as this endpoint does not return the default home dashboard
|
||||
uid: item.uid!,
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput';
|
||||
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 { ALL_FOLDER, GENERAL_FOLDER } from './ReadonlyFolderPicker';
|
||||
import { getFolderAsOption, getFoldersAsOptions } from './api';
|
||||
|
||||
function getTestContext(
|
||||
searchHits: DashboardSearchHit[] = [],
|
||||
searchHits: DashboardSearchItem[] = [],
|
||||
folderById: { id: number; title: string } = { id: 1, title: 'Folder 1' }
|
||||
) {
|
||||
jest.clearAllMocks();
|
||||
|
@ -32,8 +32,10 @@ export interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type DefaultDashboardSearchItem = Omit<DashboardSearchItem, 'uid'> & { uid?: string };
|
||||
|
||||
export type State = UserPreferencesDTO & {
|
||||
dashboards: DashboardSearchItem[];
|
||||
dashboards: DashboardSearchItem[] | DefaultDashboardSearchItem[];
|
||||
};
|
||||
|
||||
const themes: SelectableValue[] = [
|
||||
@ -75,14 +77,13 @@ const languages: Array<SelectableValue<string>> = [
|
||||
|
||||
const i18nFlag = Boolean(config.featureToggles.internationalization);
|
||||
|
||||
const DEFAULT_DASHBOARD_HOME: DashboardSearchItem = {
|
||||
const DEFAULT_DASHBOARD_HOME: DefaultDashboardSearchItem = {
|
||||
title: 'Default',
|
||||
tags: [],
|
||||
type: '' as DashboardSearchItemType,
|
||||
uid: undefined,
|
||||
uri: '',
|
||||
url: '',
|
||||
folderId: 0,
|
||||
folderTitle: '',
|
||||
folderUid: '',
|
||||
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 FRENCH_FRANCE: LocaleIdentifier = 'fr-FR';
|
||||
export const SPANISH_SPAIN: LocaleIdentifier = 'es-ES';
|
||||
export const DEFAULT_LOCALE = ENGLISH_US;
|
||||
|
||||
export const DEFAULT_LOCALE: LocaleIdentifier = ENGLISH_US;
|
||||
|
||||
export const VALID_LOCALES: LocaleIdentifier[] = [ENGLISH_US, FRENCH_FRANCE, SPANISH_SPAIN];
|
||||
export const VALID_LOCALES: string[] = [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 { DEFAULT_LOCALE, FRENCH_FRANCE, SPANISH_SPAIN, VALID_LOCALES } from './constants';
|
||||
import { DEFAULT_LOCALE, VALID_LOCALES } from './constants';
|
||||
|
||||
let i18nInstance: I18n;
|
||||
|
||||
export async function getI18n(localInput = DEFAULT_LOCALE) {
|
||||
if (i18nInstance) {
|
||||
export async function initI18n(localInput: string = DEFAULT_LOCALE) {
|
||||
const validatedLocale = VALID_LOCALES.includes(localInput) ? localInput : DEFAULT_LOCALE;
|
||||
|
||||
if (i18nInstance && i18nInstance.locale === validatedLocale) {
|
||||
return i18nInstance;
|
||||
}
|
||||
|
||||
const validatedLocale = VALID_LOCALES.includes(localInput) ? localInput : DEFAULT_LOCALE;
|
||||
|
||||
// Dynamically load the messages for the user's locale
|
||||
const imp =
|
||||
config.featureToggles.internationalization &&
|
||||
@ -53,23 +53,9 @@ interface I18nProviderProps {
|
||||
}
|
||||
export function I18nProvider({ children }: I18nProviderProps) {
|
||||
useEffect(() => {
|
||||
let loc;
|
||||
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;
|
||||
}
|
||||
}
|
||||
const locale = config.featureToggles.internationalization ? config.bootData.user.locale : DEFAULT_LOCALE;
|
||||
|
||||
getI18n(loc);
|
||||
initI18n(locale);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
@ -44,6 +44,7 @@ describe('navModelReducer', () => {
|
||||
it('then state should be correct', () => {
|
||||
const originalCfg = { id: 'cfg', subTitle: 'Organization: Org 1', text: 'Configuration' };
|
||||
const datasources = { id: 'datasources', text: 'Data Sources' };
|
||||
const correlations = { id: 'correlations', text: 'Correlations' };
|
||||
const users = { id: 'users', text: 'Users' };
|
||||
const teams = { id: 'teams', text: 'Teams' };
|
||||
const plugins = { id: 'plugins', text: 'Plugins' };
|
||||
@ -53,6 +54,7 @@ describe('navModelReducer', () => {
|
||||
const initialState = {
|
||||
cfg: { ...originalCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
||||
datasources: { ...datasources, parentItem: originalCfg },
|
||||
correlations: { ...correlations, parentItem: originalCfg },
|
||||
users: { ...users, parentItem: originalCfg },
|
||||
teams: { ...teams, parentItem: originalCfg },
|
||||
plugins: { ...plugins, parentItem: originalCfg },
|
||||
@ -66,6 +68,7 @@ describe('navModelReducer', () => {
|
||||
const expectedState = {
|
||||
cfg: { ...newCfg, children: [datasources, users, teams, plugins, orgsettings, apikeys] },
|
||||
datasources: { ...datasources, parentItem: newCfg },
|
||||
correlations: { ...correlations, parentItem: newCfg },
|
||||
users: { ...users, parentItem: newCfg },
|
||||
teams: { ...teams, parentItem: newCfg },
|
||||
plugins: { ...plugins, parentItem: newCfg },
|
||||
|
@ -79,6 +79,7 @@ export const navIndexReducer = (state: NavIndex = initialState, action: AnyActio
|
||||
...state,
|
||||
cfg: { ...state.cfg, subTitle },
|
||||
datasources: getItemWithNewSubTitle(state.datasources, subTitle),
|
||||
correlations: getItemWithNewSubTitle(state.correlations, subTitle),
|
||||
users: getItemWithNewSubTitle(state.users, subTitle),
|
||||
teams: getItemWithNewSubTitle(state.teams, 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 { getConfig } from 'app/core/config';
|
||||
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 { TokenRevokedModal } from 'app/features/users/TokenRevokedModal';
|
||||
import { DashboardDTO, FolderDTO } from 'app/types';
|
||||
@ -439,7 +439,7 @@ export class BackendSrv implements BackendService {
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
search(query: any): Promise<DashboardSearchHit[]> {
|
||||
search(query: any): Promise<DashboardSearchItem[]> {
|
||||
return this.get('/api/search', query);
|
||||
}
|
||||
|
||||
|
@ -113,7 +113,8 @@ export class SearchSrv {
|
||||
// create folder index
|
||||
for (const hit of results) {
|
||||
if (hit.type === 'dash-folder') {
|
||||
sections[hit.id] = {
|
||||
// FIXME: Use hit.uid instead
|
||||
sections[hit.id!] = {
|
||||
id: hit.id,
|
||||
uid: hit.uid,
|
||||
title: hit.title,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import impressionSrv from 'app/core/services/impression_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';
|
||||
|
||||
@ -40,7 +40,7 @@ describe('SearchSrv', () => {
|
||||
return Promise.resolve([
|
||||
{ uid: 'DSNdW0gVk', title: 'second but first' },
|
||||
{ uid: 'srx16xR4z', title: 'first but second' },
|
||||
] as DashboardSearchHit[]);
|
||||
] as DashboardSearchItem[]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
@ -70,7 +70,7 @@ describe('SearchSrv', () => {
|
||||
return Promise.resolve([
|
||||
{ uid: 'DSNdW0gVk', title: 'two' },
|
||||
{ uid: 'srx16xR4z', title: 'one' },
|
||||
] as DashboardSearchHit[]);
|
||||
] as DashboardSearchItem[]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
@ -98,7 +98,7 @@ describe('SearchSrv', () => {
|
||||
beforeEach(() => {
|
||||
searchMock.mockImplementation((options) => {
|
||||
if (options.starred) {
|
||||
return Promise.resolve([{ id: 1, title: 'starred' }] as DashboardSearchHit[]);
|
||||
return Promise.resolve([{ uid: '1', title: 'starred' }] as DashboardSearchItem[]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
@ -123,9 +123,9 @@ describe('SearchSrv', () => {
|
||||
return Promise.resolve([
|
||||
{ uid: 'srx16xR4z', title: 'starred and recent', isStarred: true },
|
||||
{ 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']);
|
||||
|
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,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
});
|
||||
};
|
||||
|
||||
@ -99,6 +100,7 @@ const mockedRules: CombinedRule[] = [
|
||||
meta: {} as PluginMeta,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -128,6 +130,7 @@ const mockedRules: CombinedRule[] = [
|
||||
meta: {} as PluginMeta,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -136,6 +136,7 @@ const mockCloudRule = {
|
||||
meta: {} as PluginMeta,
|
||||
jsonData: {} as DataSourceJsonData,
|
||||
access: 'proxy',
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -59,6 +59,7 @@ export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData
|
||||
},
|
||||
...meta,
|
||||
} as any as DataSourcePluginMeta,
|
||||
readOnly: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ describe('alertRuleToQueries', () => {
|
||||
access: 'proxy',
|
||||
meta: {} as PluginMeta,
|
||||
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 { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
@ -52,7 +51,7 @@ function setup() {
|
||||
}
|
||||
|
||||
describe('VersionSettings', () => {
|
||||
let user: UserEvent;
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Need to use delay: null here to work with fakeTimers
|
||||
|
@ -153,7 +153,9 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
|
||||
tabs={
|
||||
<TabsBar>
|
||||
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
|
||||
{data.hasChanges && (
|
||||
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
|
||||
)}
|
||||
</TabsBar>
|
||||
}
|
||||
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,7 +56,9 @@ export const SaveDashboardForm = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ register, errors }) => (
|
||||
{({ register, errors }) => {
|
||||
const messageProps = register('message');
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
{hasTimeChanged && (
|
||||
<Checkbox
|
||||
@ -84,8 +86,21 @@ export const SaveDashboardForm = ({
|
||||
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
|
||||
<TextArea
|
||||
{...messageProps}
|
||||
aria-label="message"
|
||||
value={options.message}
|
||||
onChange={(e) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
messageProps.onChange(e);
|
||||
}}
|
||||
placeholder="Add a note to describe your changes."
|
||||
autoFocus
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
<Stack alignItems="center">
|
||||
<Button variant="secondary" onClick={onCancel} fill="outline">
|
||||
@ -102,7 +117,8 @@ export const SaveDashboardForm = ({
|
||||
{!saveModel.hasChanges && <div>No changes to save</div>}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
@ -37,7 +37,7 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
|
||||
const key = `${link.title}-$${index}`;
|
||||
|
||||
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 = (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DashboardSearchHit, DashboardSearchItemType } from '../../../search/types';
|
||||
import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types';
|
||||
import { DashboardLink } from '../../state/DashboardModel';
|
||||
|
||||
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
|
||||
@ -39,7 +39,7 @@ describe('searchForTags', () => {
|
||||
});
|
||||
|
||||
describe('resolveLinks', () => {
|
||||
const setupTestContext = (dashboardId: number, searchHitId: number) => {
|
||||
const setupTestContext = (dashboardUID: string, searchHitId: string) => {
|
||||
const link: DashboardLink = {
|
||||
targetBlank: false,
|
||||
keepTime: false,
|
||||
@ -52,9 +52,9 @@ describe('resolveLinks', () => {
|
||||
type: 'dashboards',
|
||||
url: '/d/6ieouugGk/DashLinks',
|
||||
};
|
||||
const searchHits: DashboardSearchHit[] = [
|
||||
const searchHits: DashboardSearchItem[] = [
|
||||
{
|
||||
id: searchHitId,
|
||||
uid: searchHitId,
|
||||
title: 'DashLinks',
|
||||
url: '/d/6ieouugGk/DashLinks',
|
||||
isStarred: false,
|
||||
@ -70,14 +70,18 @@ describe('resolveLinks', () => {
|
||||
const sanitize = 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', () => {
|
||||
it('should filter out the calling dashboardId', () => {
|
||||
const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 1);
|
||||
it('should filter out the calling dashboardUID', () => {
|
||||
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(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0);
|
||||
@ -86,9 +90,13 @@ describe('resolveLinks', () => {
|
||||
});
|
||||
|
||||
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(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1);
|
||||
@ -96,9 +104,13 @@ describe('resolveLinks', () => {
|
||||
});
|
||||
|
||||
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(sanitize).toHaveBeenCalledTimes(1);
|
||||
@ -106,9 +118,13 @@ describe('resolveLinks', () => {
|
||||
});
|
||||
|
||||
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(sanitizeUrl).toHaveBeenCalledTimes(1);
|
||||
|
@ -7,7 +7,7 @@ import { sanitize, sanitizeUrl } from '@grafana/data/src/text/sanitize';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
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 { DashboardLink } from '../../state/DashboardModel';
|
||||
@ -15,7 +15,7 @@ import { DashboardLink } from '../../state/DashboardModel';
|
||||
interface Props {
|
||||
link: DashboardLink;
|
||||
linkInfo: { title: string; href: string };
|
||||
dashboardId: number;
|
||||
dashboardUID: string;
|
||||
}
|
||||
|
||||
export const DashboardLinksDashboard = (props: Props) => {
|
||||
@ -55,7 +55,7 @@ export const DashboardLinksDashboard = (props: Props) => {
|
||||
{resolvedLinks.length > 0 &&
|
||||
resolvedLinks.map((resolvedLink, index) => {
|
||||
return (
|
||||
<li role="none" key={`dashlinks-dropdown-item-${resolvedLink.id}-${index}`}>
|
||||
<li role="none" key={`dashlinks-dropdown-item-${resolvedLink.uid}-${index}`}>
|
||||
<a
|
||||
role="menuitem"
|
||||
href={resolvedLink.url}
|
||||
@ -82,7 +82,7 @@ export const DashboardLinksDashboard = (props: Props) => {
|
||||
return (
|
||||
<LinkElement
|
||||
link={link}
|
||||
key={`dashlinks-list-item-${resolvedLink.id}-${index}`}
|
||||
key={`dashlinks-list-item-${resolvedLink.uid}-${index}`}
|
||||
data-testid={selectors.components.DashboardLinks.container}
|
||||
>
|
||||
<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 result = useAsync(() => searchForTags(tags), [tags, opened]);
|
||||
if (!result.value) {
|
||||
return [];
|
||||
}
|
||||
return resolveLinks(dashboardId, link, result.value);
|
||||
return resolveLinks(dashboardUID, link, result.value);
|
||||
};
|
||||
|
||||
interface ResolvedLinkDTO {
|
||||
id: number;
|
||||
uid: string;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
@ -138,17 +138,17 @@ interface ResolvedLinkDTO {
|
||||
export async function searchForTags(
|
||||
tags: string[],
|
||||
dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv }
|
||||
): Promise<DashboardSearchHit[]> {
|
||||
): Promise<DashboardSearchItem[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveLinks(
|
||||
dashboardId: number,
|
||||
dashboardUID: string,
|
||||
link: DashboardLink,
|
||||
searchHits: DashboardSearchHit[],
|
||||
searchHits: DashboardSearchItem[],
|
||||
dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = {
|
||||
getLinkSrv,
|
||||
sanitize,
|
||||
@ -156,14 +156,14 @@ export function resolveLinks(
|
||||
}
|
||||
): ResolvedLinkDTO[] {
|
||||
return searchHits
|
||||
.filter((searchHit) => searchHit.id !== dashboardId)
|
||||
.filter((searchHit) => searchHit.uid !== dashboardUID)
|
||||
.map((searchHit) => {
|
||||
const id = searchHit.id;
|
||||
const uid = searchHit.uid;
|
||||
const title = dependencies.sanitize(searchHit.title);
|
||||
const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url });
|
||||
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),
|
||||
jsonData: {},
|
||||
access: 'proxy',
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
this.interval = '1min';
|
||||
|
@ -199,7 +199,6 @@ describe('AddToDashboardButton', () => {
|
||||
});
|
||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
uid: 'someUid',
|
||||
isStarred: false,
|
||||
items: [],
|
||||
@ -242,7 +241,6 @@ describe('AddToDashboardButton', () => {
|
||||
});
|
||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
uid: 'someUid',
|
||||
isStarred: false,
|
||||
items: [],
|
||||
@ -359,7 +357,6 @@ describe('AddToDashboardButton', () => {
|
||||
jest.spyOn(backendSrv, 'getDashboardByUid').mockRejectedValue('SOME ERROR');
|
||||
jest.spyOn(backendSrv, 'search').mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
uid: 'someUid',
|
||||
isStarred: false,
|
||||
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