Merge branch 'main' into gabor/logs-volume-disable

This commit is contained in:
Gábor Farkas 2022-09-05 13:50:30 +02:00
commit ff9f2d1f51
132 changed files with 2457 additions and 805 deletions

View File

@ -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"],

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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",

View File

@ -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 (

View File

@ -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",

View File

@ -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();

View File

@ -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>

View File

@ -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'};
`
),
};
});

View File

@ -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>) {

View File

@ -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}

View File

@ -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,35 +36,37 @@ 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),
focused &&
return {
wrapper: cx(
styles.wrapper,
sharedInputStyle(theme, invalid),
focused &&
css`
${focusCss(theme.v1)}
`,
disabled && styles.inputDisabled,
css`
${focusCss(theme.v1)}
`,
disabled && styles.inputDisabled,
css`
position: relative;
box-sizing: border-box;
/* The display property is set by the styles prop in SelectBase because it's dependant on the width prop */
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
justify-content: space-between;
position: relative;
box-sizing: border-box;
/* The display property is set by the styles prop in SelectBase because it's dependant on the width prop */
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
justify-content: space-between;
min-height: 32px;
height: auto;
max-width: 100%;
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'};
`
),
};
});
/* Input padding is applied to the InputControl so the menu is aligned correctly */
padding: 0;
cursor: ${disabled ? 'not-allowed' : 'pointer'};
`
),
};
}
);

View File

@ -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>;
}

View File

@ -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();

View File

@ -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',

View File

@ -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",

View File

@ -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();

View File

@ -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}>

View File

@ -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');
});

View File

@ -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);
}

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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",

View File

@ -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)
}

View File

@ -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"`

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View 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)
)

View File

@ -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,

View File

@ -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{

View File

@ -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)
}

View File

@ -41,26 +41,27 @@ 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,
DataSourceService: dataSourceService,
RouteRegister: routeRegister,
SQLStore: sqlStore,
KVStore: kvStore,
ExpressionService: expressionService,
DataProxy: dataProxy,
QuotaService: quotaService,
SecretsService: secretsService,
Metrics: m,
Log: log.New("ngalert"),
NotificationService: notificationService,
folderService: folderService,
accesscontrol: ac,
dashboardService: dashboardService,
renderService: renderService,
bus: bus,
Cfg: cfg,
DataSourceCache: dataSourceCache,
DataSourceService: dataSourceService,
RouteRegister: routeRegister,
SQLStore: sqlStore,
KVStore: kvStore,
ExpressionService: expressionService,
DataProxy: dataProxy,
QuotaService: quotaService,
SecretsService: secretsService,
Metrics: m,
Log: log.New("ngalert"),
NotificationService: notificationService,
folderService: folderService,
accesscontrol: ac,
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) {

View File

@ -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 {

View File

@ -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{

View File

@ -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 {

View File

@ -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)
@ -67,29 +64,29 @@ func TestAPIGetPublicDashboard(t *testing.T) {
accessToken := fmt.Sprintf("%x", token)
testCases := []struct {
Name string
AccessToken string
ExpectedHttpResponse int
PublicDashboardResult *models.Dashboard
PublicDashboardErr error
Name string
AccessToken string
ExpectedHttpResponse int
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,
Name: "It should return 404 if no public dashboard",
AccessToken: accessToken,
ExpectedHttpResponse: http.StatusNotFound,
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{

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)
}
})
}

View File

@ -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")

View File

@ -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",

View File

@ -81,30 +81,78 @@ func (i *orgIndex) readerForIndex(idxType indexType) (*bluge.Reader, func(), err
}
type searchIndex struct {
mu sync.RWMutex
loader dashboardLoader
perOrgIndex map[int64]*orgIndex
eventStore eventStore
logger log.Logger
buildSignals chan buildSignal
extender DocumentExtender
folderIdLookup folderUIDLookup
syncCh chan chan 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
extender DocumentExtender
folderIdLookup folderUIDLookup
syncCh chan chan struct{}
}
func newSearchIndex(dashLoader dashboardLoader, evStore eventStore, extender DocumentExtender, folderIDs folderUIDLookup) *searchIndex {
return &searchIndex{
loader: dashLoader,
eventStore: evStore,
perOrgIndex: map[int64]*orgIndex{},
logger: log.New("searchIndex"),
buildSignals: make(chan buildSignal),
extender: extender,
folderIdLookup: folderIDs,
syncCh: make(chan chan struct{}),
loader: dashLoader,
eventStore: evStore,
perOrgIndex: map[int64]*orgIndex{},
initializedOrgs: map[int64]bool{},
logger: log.New("searchIndex"),
buildSignals: make(chan buildSignal),
extender: extender,
folderIdLookup: folderIDs,
syncCh: make(chan chan struct{}),
}
}
func (i *searchIndex) isInitialized(_ context.Context, orgId int64) IsSearchReadyResponse {
i.initializationMutex.RLock()
orgInitialized := i.initializedOrgs[orgId]
initialInitComplete := i.initialIndexingComplete
i.initializationMutex.RUnlock()
if orgInitialized && initialInitComplete {
return IsSearchReadyResponse{IsReady: true}
}
if !initialInitComplete {
return IsSearchReadyResponse{IsReady: false, Reason: "initial-indexing-ongoing"}
}
i.triggerBuildingOrgIndex(orgId)
return IsSearchReadyResponse{IsReady: false, Reason: "org-indexing-ongoing"}
}
func (i *searchIndex) triggerBuildingOrgIndex(orgId int64) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
doneIndexing := make(chan error, 1)
signal := buildSignal{orgID: orgId, done: doneIndexing}
select {
case i.buildSignals <- signal:
case <-ctx.Done():
i.logger.Warn("Failed to send a build signal to initialize org index", "orgId", orgId)
return
}
select {
case err := <-doneIndexing:
if err != nil {
i.logger.Error("Failed to build org index", "orgId", orgId, "error", err)
} else {
i.logger.Debug("Successfully built org index", "orgId", orgId)
}
case <-ctx.Done():
i.logger.Warn("Building org index timeout", "orgId", orgId)
}
}()
}
func (i *searchIndex) sync(ctx context.Context) error {
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 {

View File

@ -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)

View File

@ -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{

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)
}

View 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
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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
@ -34,8 +36,19 @@ const DatasourceUID = "grafana"
// This is important to do since otherwise we will only get a
// not implemented error response from plugin at runtime.
var (
_ backend.QueryDataHandler = (*Service)(nil)
_ backend.CheckHealthHandler = (*Service)(nil)
_ 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 {

View File

@ -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{}

View File

@ -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

View File

@ -20,6 +20,7 @@ describe('InputDatasource', () => {
name: 'xxx',
meta: {} as PluginMeta,
access: 'proxy',
readOnly: false,
jsonData: {
data,
},

View File

@ -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' } });

View File

@ -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' }),

View File

@ -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}`,
};

View File

@ -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

View File

@ -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!,

View File

@ -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();

View File

@ -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: '',

View File

@ -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];

View File

@ -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 (

View File

@ -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 },

View File

@ -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),

View File

@ -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);
}

View File

@ -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,

View File

@ -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']);

View 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);

View File

@ -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,
},
},
},

View File

@ -136,6 +136,7 @@ const mockCloudRule = {
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
access: 'proxy',
readOnly: false,
},
},
};

View File

@ -59,6 +59,7 @@ export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData
},
...meta,
} as any as DataSourcePluginMeta,
readOnly: false,
...partial,
};
}

View File

@ -55,6 +55,7 @@ describe('alertRuleToQueries', () => {
access: 'proxy',
meta: {} as PluginMeta,
jsonData: {} as DataSourceJsonData,
readOnly: false,
},
},
};

View 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();
});
});
});

View 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
);

View 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>
);
};

View File

@ -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>
</>
);
}

View File

@ -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>
);
};

View 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'] };

View 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 };
};

View File

@ -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"
/>
);
};

View File

@ -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>
);

View 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>
);
}

View 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 }),
})),
];
}

View 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'>;

View 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,
},
};
};

View File

@ -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

View File

@ -153,7 +153,9 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
tabs={
<TabsBar>
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
{data.hasChanges && (
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
)}
</TabsBar>
}
expandable

View File

@ -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');
});
});
});

View File

@ -56,53 +56,69 @@ export const SaveDashboardForm = ({
}
}}
>
{({ register, errors }) => (
<Stack direction="column" gap={2}>
{hasTimeChanged && (
<Checkbox
checked={!!options.saveTimerange}
onChange={() =>
{({ register, errors }) => {
const messageProps = register('message');
return (
<Stack direction="column" gap={2}>
{hasTimeChanged && (
<Checkbox
checked={!!options.saveTimerange}
onChange={() =>
onOptionsChange({
...options,
saveTimerange: !options.saveTimerange,
})
}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
checked={!!options.saveVariables}
onChange={() =>
onOptionsChange({
...options,
saveVariables: !options.saveVariables,
})
}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
<TextArea
{...messageProps}
aria-label="message"
value={options.message}
onChange={(e) => {
onOptionsChange({
...options,
saveTimerange: !options.saveTimerange,
})
}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
message: e.currentTarget.value,
});
messageProps.onChange(e);
}}
placeholder="Add a note to describe your changes."
autoFocus
rows={5}
/>
)}
{hasVariableChanged && (
<Checkbox
checked={!!options.saveVariables}
onChange={() =>
onOptionsChange({
...options,
saveVariables: !options.saveVariables,
})
}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
<Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button
type="submit"
disabled={!saveModel.hasChanges}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
Save
</Button>
{!saveModel.hasChanges && <div>No changes to save</div>}
<Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button
type="submit"
disabled={!saveModel.hasChanges}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
Save
</Button>
{!saveModel.hasChanges && <div>No changes to save</div>}
</Stack>
</Stack>
</Stack>
)}
);
}}
</Form>
);
};

View File

@ -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 = (

View File

@ -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);

View File

@ -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 };
});
}

View File

@ -29,6 +29,7 @@ export class PublicDashboardDataSource extends DataSourceApi<any> {
uid: PublicDashboardDataSource.resolveUid(datasource),
jsonData: {},
access: 'proxy',
readOnly: true,
});
this.interval = '1min';

View File

@ -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