Merge remote-tracking branch 'origin/main' into zoltan/exemplars-e2e-test

This commit is contained in:
Zoltán Bedi
2021-05-12 15:31:29 +02:00
85 changed files with 2464 additions and 1246 deletions
+2 -1
View File
@@ -2,8 +2,9 @@ load('scripts/pr.star', 'pr_pipelines')
load('scripts/main.star', 'main_pipelines')
load('scripts/release.star', 'release_pipelines', 'test_release_pipelines')
load('scripts/version.star', 'version_branch_pipelines')
load('scripts/vault.star', 'secrets')
def main(ctx):
edition = 'oss'
return pr_pipelines(edition=edition) + main_pipelines(edition=edition) + release_pipelines() + \
test_release_pipelines() + version_branch_pipelines()
test_release_pipelines() + version_branch_pipelines() + secrets()
+34
View File
@@ -1476,6 +1476,9 @@ services:
- name: memcached
image: memcached:1.6.9-alpine
image_pull_secrets:
- dockerconfigjson
trigger:
ref:
- refs/tags/v*
@@ -1543,6 +1546,9 @@ steps:
depends_on:
- initialize
image_pull_secrets:
- dockerconfigjson
trigger:
ref:
- refs/tags/v*
@@ -2408,6 +2414,9 @@ services:
- name: memcached
image: memcached:1.6.9-alpine
image_pull_secrets:
- dockerconfigjson
trigger:
event:
- custom
@@ -2475,6 +2484,9 @@ steps:
depends_on:
- initialize
image_pull_secrets:
- dockerconfigjson
trigger:
event:
- custom
@@ -3314,6 +3326,9 @@ services:
- name: memcached
image: memcached:1.6.9-alpine
image_pull_secrets:
- dockerconfigjson
trigger:
ref:
- refs/heads/v*
@@ -3377,6 +3392,9 @@ steps:
depends_on:
- initialize
image_pull_secrets:
- dockerconfigjson
trigger:
ref:
- refs/heads/v*
@@ -3414,4 +3432,20 @@ depends_on:
- enterprise-build-release-branch
- enterprise-windows-release-branch
---
kind: secret
name: dockerconfigjson
get:
path: secret/data/common/gcr
name: .dockerconfigjson
---
kind: secret
name: github_token
get:
path: infra/data/ci/github/grafanabot
name: pat
...
@@ -8,3 +8,4 @@ export * from './ArrayDataFrame';
export * from './DataFrameJSON';
export * from './StreamingDataFrame';
export * from './frameComparisons';
export { anySeriesWithTimeField } from './utils';
@@ -0,0 +1,78 @@
import { toDataFrame } from './processDataFrame';
import { FieldType } from '../types';
import { anySeriesWithTimeField } from './utils';
describe('anySeriesWithTimeField', () => {
describe('single frame', () => {
test('without time field', () => {
const frameA = toDataFrame({
fields: [
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
expect(anySeriesWithTimeField([frameA])).toBeFalsy();
});
test('with time field', () => {
const frameA = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
expect(anySeriesWithTimeField([frameA])).toBeTruthy();
});
});
describe('multiple frames', () => {
test('without time field', () => {
const frameA = toDataFrame({
fields: [
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
const frameB = toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }],
});
expect(anySeriesWithTimeField([frameA, frameB])).toBeFalsy();
});
test('with time field in any frame', () => {
const frameA = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
const frameB = toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [1, 2, 3] }],
});
const frameC = toDataFrame({
fields: [{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] }],
});
expect(anySeriesWithTimeField([frameA, frameB, frameC])).toBeTruthy();
});
test('with time field in a all frames', () => {
const frameA = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
const frameB = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
{ name: 'name', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'value', type: FieldType.number, values: [1, 2, 3] },
],
});
expect(anySeriesWithTimeField([frameA, frameB])).toBeTruthy();
});
});
});
+20 -5
View File
@@ -1,12 +1,27 @@
import { DataFrame, FieldType } from '../types/dataFrame';
import { getTimeField } from './processDataFrame';
export const isTimeSerie = (frame: DataFrame): boolean => {
export function isTimeSerie(frame: DataFrame) {
if (frame.fields.length > 2) {
return false;
}
return !!frame.fields.find((field) => field.type === FieldType.time);
};
return Boolean(frame.fields.find((field) => field.type === FieldType.time));
}
export const isTimeSeries = (data: DataFrame[]): boolean => {
export function isTimeSeries(data: DataFrame[]) {
return !data.find((frame) => !isTimeSerie(frame));
};
}
/**
* Indicates if there is any time field in the array of data frames
* @param data
*/
export function anySeriesWithTimeField(data: DataFrame[]) {
for (let i = 0; i < data.length; i++) {
const timeField = getTimeField(data[i]);
if (timeField.timeField !== undefined && timeField.timeIndex !== undefined) {
return true;
}
}
return false;
}
+2
View File
@@ -2,6 +2,7 @@ import { Labels } from './data';
import { GraphSeriesXY } from './graph';
import { DataFrame } from './dataFrame';
import { AbsoluteTimeRange } from './time';
import { DataQuery } from './datasource';
/**
* Mapping of log level abbreviation to canonical log level.
@@ -85,6 +86,7 @@ export interface LogsModel {
rows: LogRowModel[];
series?: GraphSeriesXY[];
visibleRange?: AbsoluteTimeRange;
queries?: DataQuery[];
}
export interface LogSearchMatch {
+8 -3
View File
@@ -37,16 +37,21 @@ describe('parseKeyValue', () => {
it('should parse numeric params', () => {
const obj = urlUtil.parseKeyValue('num1=12&num2=12.2');
expect(obj).toEqual({ num1: 12, num2: 12.2 });
expect(obj).toEqual({ num1: '12', num2: '12.2' });
});
it('should not parse empty strinhg as number', () => {
it('should not parse empty string as number', () => {
const obj = urlUtil.parseKeyValue('num1=&num2=12.2');
expect(obj).toEqual({ num1: '', num2: 12.2 });
expect(obj).toEqual({ num1: '', num2: '12.2' });
});
it('should parse boolean params', () => {
const obj = urlUtil.parseKeyValue('bool1&bool2=true&bool3=false');
expect(obj).toEqual({ bool1: true, bool2: true, bool3: false });
});
it('should parse number like params as strings', () => {
const obj = urlUtil.parseKeyValue('custom=&custom1=001&custom2=002&custom3');
expect(obj).toEqual({ custom: '', custom1: '001', custom2: '002', custom3: true });
});
});
+1 -2
View File
@@ -2,7 +2,6 @@
* @preserve jquery-param (c) 2015 KNOWLEDGECODE | MIT
*/
import { toNumber } from 'lodash';
import { ExploreUrlState } from '../types/explore';
/**
@@ -156,7 +155,7 @@ export function parseKeyValue(keyValue: string) {
let parsedVal: any;
if (typeof val === 'string' && val !== '') {
parsedVal = val === 'true' || val === 'false' ? val === 'true' : toNumber(val);
parsedVal = val === 'true' || val === 'false' ? val === 'true' : val;
} else {
parsedVal = val;
}
@@ -7,7 +7,7 @@ describe('LocationService', () => {
expect(locationService.getSearchObject()).toEqual({
query1: false,
query2: 123,
query2: '123',
query3: 'text',
});
});
@@ -1,8 +1,8 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { Meta, Story } from '@storybook/react';
import { SecretFormField } from './SecretFormField';
import { SecretFormField, Props } from './SecretFormField';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
@@ -10,31 +10,48 @@ export default {
title: 'Forms/SecretFormField',
component: SecretFormField,
decorators: [withCenteredStory],
};
parameters: {
controls: {
exclude: ['onReset'],
},
knobs: {
disable: true,
},
},
argTypes: {
labelWidth: { control: { type: 'range', min: 0, max: 30 } },
inputWidth: { control: { type: 'range', min: 0, max: 30 } },
tooltip: { control: { type: 'text' } },
},
} as Meta;
const getSecretFormFieldKnobs = () => {
return {
isConfigured: boolean('Set configured state', false),
};
};
export const basic = () => {
const knobs = getSecretFormFieldKnobs();
export const Basic: Story<Props> = (args) => {
return (
<UseState initialState="Input value">
{(value, setValue) => (
<SecretFormField
label={'Secret field'}
labelWidth={10}
label={args.label}
labelWidth={args.labelWidth}
value={value}
isConfigured={knobs.isConfigured}
isConfigured={args.isConfigured}
onChange={(e) => setValue(e.currentTarget.value)}
onReset={() => {
action('Value was reset')('');
setValue('');
}}
inputWidth={args.inputWidth}
tooltip={args.tooltip}
placeholder={args.placeholder}
/>
)}
</UseState>
);
};
Basic.args = {
label: 'Secret field',
labelWidth: 10,
isConfigured: false,
inputWidth: 12,
tooltip: 'this is a tooltip',
placeholder: 'Password',
};
@@ -3,10 +3,12 @@ import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/st
import { SelectableValue } from '@grafana/data';
import { Icon, Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from '@grafana/ui';
import { getAvailableIcons, IconName } from '../../types';
import { select, boolean, number } from '@storybook/addon-knobs';
import { SelectCommonProps } from './types';
import { Meta, Story } from '@storybook/react';
import { kebabCase } from 'lodash';
import { generateOptions } from './mockOptions';
import mdx from './Select.mdx';
import { auto } from '@popperjs/core';
export default {
title: 'Forms/Select',
@@ -17,10 +19,56 @@ export default {
docs: {
page: mdx,
},
knobs: {
disable: true,
},
controls: {
exclude: [
'getOptionValue',
'getOptionLabel',
'formatCreateLabel',
'filterOption',
'className',
'components',
'defaultValue',
'id',
'inputId',
'onBlur',
'onChange',
'onCloseMenu',
'onCreateOption',
'onInputChange',
'onKeyDown',
'onOpenMenu',
'prefix',
'renderControl',
'options',
'isOptionDisabled',
'maxVisibleValues',
'aria-label',
'noOptionsMessage',
'menuPosition',
'value',
],
},
},
};
const BEHAVIOUR_GROUP = 'Behaviour props';
args: {
width: 0,
disabled: false,
isLoading: false,
invalid: false,
icon: 'arrow-down',
},
argTypes: {
width: { control: { type: 'range', min: 1, max: 100 } },
icon: {
control: {
type: 'select',
options: getAvailableIcons(),
},
},
},
} as Meta;
const loadAsyncOptions = () => {
return new Promise<Array<SelectableValue<string>>>((resolve) => {
@@ -30,63 +78,16 @@ const loadAsyncOptions = () => {
});
};
const getKnobs = () => {
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
const prefixSuffixOpts = {
None: null,
Text: '$',
...getAvailableIcons().reduce<Record<string, string>>((prev, c) => {
return {
...prev,
[`Icon: ${c}`]: `icon-${c}`,
};
}, {}),
};
const VISUAL_GROUP = 'Visual options';
// ---
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
const width = number('Width', 0, undefined, VISUAL_GROUP);
let prefixEl: any = prefix;
if (prefix && prefix.match(/icon-/g)) {
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconName} />;
}
return {
width,
disabled,
invalid,
loading,
prefixEl,
};
const getPrefix = (prefix: string) => {
const prefixEl = <Icon name={prefix as IconName} />;
return prefixEl;
};
const getMultiSelectKnobs = () => {
const isClearable = boolean('Clearable', false, BEHAVIOUR_GROUP);
const closeMenuOnSelect = boolean('Close on Select', false, BEHAVIOUR_GROUP);
const maxVisibleValues = number('Max. visible values', 5, undefined, BEHAVIOUR_GROUP);
interface StoryProps extends Partial<SelectCommonProps<string>> {
icon: string;
}
return {
isClearable,
closeMenuOnSelect,
maxVisibleValues,
};
};
const getDynamicProps = () => {
const knobs = getKnobs();
return {
width: knobs.width,
disabled: knobs.disabled,
isLoading: knobs.loading,
invalid: knobs.invalid,
prefix: knobs.prefixEl,
};
};
export const Basic = () => {
export const Basic: Story<StoryProps> = (args) => {
const [value, setValue] = useState<SelectableValue<string>>();
return (
@@ -97,16 +98,16 @@ export const Basic = () => {
onChange={(v) => {
setValue(v);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
/**
* Uses plain values instead of SelectableValue<T>
*/
export const BasicSelectPlainValue = () => {
export const BasicSelectPlainValue: Story<StoryProps> = (args) => {
const [value, setValue] = useState<string>();
return (
<>
@@ -116,16 +117,16 @@ export const BasicSelectPlainValue = () => {
onChange={(v) => {
setValue(v.value);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
/**
* Uses plain values instead of SelectableValue<T>
*/
export const SelectWithOptionDescriptions = () => {
export const SelectWithOptionDescriptions: Story = (args) => {
// TODO this is not working with new Select
const [value, setValue] = useState<number>();
@@ -148,7 +149,8 @@ export const SelectWithOptionDescriptions = () => {
onChange={(v) => {
setValue(v.value);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
@@ -157,7 +159,7 @@ export const SelectWithOptionDescriptions = () => {
/**
* Uses plain values instead of SelectableValue<T>
*/
export const MultiPlainValue = () => {
export const MultiPlainValue: Story = (args) => {
const [value, setValue] = useState<string[]>();
return (
@@ -168,13 +170,14 @@ export const MultiPlainValue = () => {
onChange={(v) => {
setValue(v.map((v: any) => v.value));
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
export const MultiSelectWithOptionGroups = () => {
export const MultiSelectWithOptionGroups: Story = (args) => {
const [value, setValue] = useState<string[]>();
return (
@@ -188,13 +191,14 @@ export const MultiSelectWithOptionGroups = () => {
onChange={(v) => {
setValue(v.map((v: any) => v.value));
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
export const MultiSelectBasic = () => {
export const MultiSelectBasic: Story = (args) => {
const [value, setValue] = useState<Array<SelectableValue<string>>>([]);
return (
@@ -205,14 +209,19 @@ export const MultiSelectBasic = () => {
onChange={(v) => {
setValue(v);
}}
{...getDynamicProps()}
{...getMultiSelectKnobs()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
MultiSelectBasic.args = {
isClearable: false,
closeMenuOnSelect: false,
maxVisibleValues: 5,
};
export const MultiSelectAsync = () => {
export const MultiSelectAsync: Story = (args) => {
const [value, setValue] = useState<Array<SelectableValue<string>>>();
return (
@@ -223,13 +232,16 @@ export const MultiSelectAsync = () => {
onChange={(v) => {
setValue(v);
}}
allowCustomValue
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
);
};
MultiSelectAsync.args = {
allowCustomValue: false,
};
export const BasicSelectAsync = () => {
export const BasicSelectAsync: Story = (args) => {
const [value, setValue] = useState<SelectableValue<string>>();
return (
@@ -240,12 +252,13 @@ export const BasicSelectAsync = () => {
onChange={(v) => {
setValue(v);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
);
};
export const AutoMenuPlacement = () => {
export const AutoMenuPlacement: Story = (args) => {
const [value, setValue] = useState<SelectableValue<string>>();
return (
@@ -257,14 +270,18 @@ export const AutoMenuPlacement = () => {
onChange={(v) => {
setValue(v);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</div>
</>
);
};
AutoMenuPlacement.args = {
menuPlacement: auto,
};
export const CustomValueCreation = () => {
export const CustomValueCreation: Story = (args) => {
const [value, setValue] = useState<SelectableValue<string>>();
const [customOptions, setCustomOptions] = useState<Array<SelectableValue<string>>>([]);
const options = generateOptions();
@@ -276,14 +293,18 @@ export const CustomValueCreation = () => {
onChange={(v) => {
setValue(v);
}}
allowCustomValue
allowCustomValue={args.allowCustomValue}
onCreateOption={(v) => {
const customValue: SelectableValue<string> = { value: kebabCase(v), label: v };
setCustomOptions([...customOptions, customValue]);
setValue(customValue);
}}
{...getDynamicProps()}
prefix={getPrefix(args.icon)}
{...args}
/>
</>
);
};
CustomValueCreation.args = {
allowCustomValue: true,
};
@@ -1,30 +1,47 @@
import React from 'react';
import { Slider } from '@grafana/ui';
import { select, number, boolean } from '@storybook/addon-knobs';
import { SliderProps } from './types';
import { Story, Meta } from '@storybook/react';
export default {
title: 'Forms/Slider',
component: Slider,
};
parameters: {
controls: {
exclude: ['step', 'formatTooltipResult', 'onChange', 'onAfterChange', 'value', 'tooltipAlwaysVisible'],
},
knobs: {
disabled: true,
},
},
argTypes: {
isStep: { name: 'Step' },
orientation: { control: { type: 'select', options: ['horizontal', 'vertical'] } },
},
} as Meta;
const getKnobs = () => {
return {
min: number('min', 0),
max: number('max', 100),
step: boolean('enable step', false),
orientation: select('orientation', ['horizontal', 'vertical'], 'horizontal'),
reverse: boolean('reverse', false),
};
};
interface StoryProps extends Partial<SliderProps> {
isStep: boolean;
}
const SliderWrapper = () => {
const { min, max, orientation, reverse, step } = getKnobs();
const stepValue = step ? 10 : undefined;
export const Basic: Story<StoryProps> = (args) => {
return (
<div style={{ width: '300px', height: '300px' }}>
<Slider min={min} max={max} step={stepValue} orientation={orientation} value={10} reverse={reverse} />
<Slider
step={args.isStep ? 10 : undefined}
value={args.value}
min={args.min as number}
max={args.max as number}
{...args}
/>
</div>
);
};
export const basic = () => <SliderWrapper />;
Basic.args = {
min: 0,
max: 100,
value: 10,
isStep: false,
orientation: 'horizontal',
reverse: false,
};
@@ -11,9 +11,16 @@ export interface Props {
tags?: string[];
onChange: (tags: string[]) => void;
width?: number;
className?: string;
}
export const TagsInput: FC<Props> = ({ placeholder = 'New tag (enter key to add)', tags = [], onChange, width }) => {
export const TagsInput: FC<Props> = ({
placeholder = 'New tag (enter key to add)',
tags = [],
onChange,
width,
className,
}) => {
const [newTagName, setNewName] = useState('');
const styles = useStyles(getStyles);
const theme = useTheme2();
@@ -28,7 +35,9 @@ export const TagsInput: FC<Props> = ({ placeholder = 'New tag (enter key to add)
const onAdd = (event: React.MouseEvent) => {
event.preventDefault();
onChange(tags.concat(newTagName));
if (!tags.includes(newTagName)) {
onChange(tags.concat(newTagName));
}
setNewName('');
};
@@ -41,7 +50,7 @@ export const TagsInput: FC<Props> = ({ placeholder = 'New tag (enter key to add)
};
return (
<div className={cx(styles.wrapper, width ? css({ width: theme.spacing(width) }) : '')}>
<div className={cx(styles.wrapper, className, width ? css({ width: theme.spacing(width) }) : '')}>
<div className={tags?.length ? styles.tags : undefined}>
{tags?.map((tag: string, index: number) => {
return <TagItem key={`${tag}-${index}`} name={tag} onRemove={onRemove} />;
+1
View File
@@ -203,6 +203,7 @@ func (hs *HTTPServer) registerRoutes() {
orgRoute.Put("/", reqOrgAdmin, bind(dtos.UpdateOrgForm{}), routing.Wrap(UpdateOrgCurrent))
orgRoute.Put("/address", reqOrgAdmin, bind(dtos.UpdateOrgAddressForm{}), routing.Wrap(UpdateOrgAddressCurrent))
orgRoute.Get("/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(hs.GetOrgUsersForCurrentOrg))
orgRoute.Get("/users/search", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRead, accesscontrol.ScopeUsersAll), routing.Wrap(hs.SearchOrgUsersWithPaging))
orgRoute.Post("/users", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersAdd, accesscontrol.ScopeUsersAll), quota("user"), bind(models.AddOrgUserCommand{}), routing.Wrap(AddOrgUserToCurrentOrg))
orgRoute.Patch("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRoleUpdate, usersScope), bind(models.UpdateOrgUserCommand{}), routing.Wrap(UpdateOrgUserForCurrentOrg))
orgRoute.Delete("/users/:userId", authorize(reqOrgAdmin, accesscontrol.ActionOrgUsersRemove, usersScope), routing.Wrap(RemoveOrgUserForCurrentOrg))
+41 -1
View File
@@ -157,6 +157,47 @@ func (hs *HTTPServer) getOrgUsersHelper(query *models.GetOrgUsersQuery, signedIn
return filteredUsers, nil
}
// SearchOrgUsersWithPaging is an HTTP handler to search for org users with paging.
// GET /api/org/users/search
func (hs *HTTPServer) SearchOrgUsersWithPaging(c *models.ReqContext) response.Response {
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
}
page := c.QueryInt("page")
if page < 1 {
page = 1
}
query := &models.SearchOrgUsersQuery{
OrgID: c.OrgId,
Query: c.Query("query"),
Limit: perPage,
Page: page,
}
if err := hs.SQLStore.SearchOrgUsers(query); err != nil {
return response.Error(500, "Failed to get users for current organization", err)
}
filteredUsers := make([]*models.OrgUserDTO, 0, len(query.Result.OrgUsers))
for _, user := range query.Result.OrgUsers {
if dtos.IsHiddenUser(user.Login, c.SignedInUser, hs.Cfg) {
continue
}
user.AvatarUrl = dtos.GetGravatarUrl(user.Email)
filteredUsers = append(filteredUsers, user)
}
query.Result.OrgUsers = filteredUsers
query.Result.Page = page
query.Result.PerPage = perPage
return response.JSON(200, query.Result)
}
// PATCH /api/org/users/:userId
func UpdateOrgUserForCurrentOrg(c *models.ReqContext, cmd models.UpdateOrgUserCommand) response.Response {
cmd.OrgId = c.OrgId
@@ -175,7 +216,6 @@ func updateOrgUserHelper(cmd models.UpdateOrgUserCommand) response.Response {
if !cmd.Role.IsValid() {
return response.Error(400, "Invalid role specified", nil)
}
if err := bus.Dispatch(&cmd); err != nil {
if errors.Is(err, models.ErrLastOrgAdmin) {
return response.Error(400, "Cannot change role so that there is no organization admin left", nil)
+52
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"testing"
@@ -8,6 +9,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -24,10 +26,24 @@ func setUpGetOrgUsersHandler() {
})
}
func setUpGetOrgUsersDB(t *testing.T, sqlStore *sqlstore.SQLStore) {
setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1
_, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{Email: "testUser@grafana.com", Login: "testUserLogin"})
require.NoError(t, err)
_, err = sqlStore.CreateUser(context.Background(), models.CreateUserCommand{Email: "user1@grafana.com", Login: "user1"})
require.NoError(t, err)
_, err = sqlStore.CreateUser(context.Background(), models.CreateUserCommand{Email: "user2@grafana.com", Login: "user2"})
require.NoError(t, err)
}
func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
settings := setting.NewCfg()
hs := &HTTPServer{Cfg: settings}
sqlStore := sqlstore.InitTestDB(t)
loggedInUserScenario(t, "When calling GET on", "api/org/users", func(sc *scenarioContext) {
setUpGetOrgUsersHandler()
@@ -42,6 +58,42 @@ func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) {
assert.Len(t, resp, 3)
})
loggedInUserScenario(t, "When calling GET on", "api/org/users/search", func(sc *scenarioContext) {
setUpGetOrgUsersDB(t, sqlStore)
sc.handlerFunc = hs.SearchOrgUsersWithPaging
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
require.Equal(t, http.StatusOK, sc.resp.Code)
var resp models.SearchOrgUsersQueryResult
err := json.Unmarshal(sc.resp.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Len(t, resp.OrgUsers, 3)
assert.Equal(t, int64(3), resp.TotalCount)
assert.Equal(t, 1000, resp.PerPage)
assert.Equal(t, 1, resp.Page)
})
loggedInUserScenario(t, "When calling GET with page and limit query parameters on", "api/org/users/search", func(sc *scenarioContext) {
setUpGetOrgUsersDB(t, sqlStore)
sc.handlerFunc = hs.SearchOrgUsersWithPaging
sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "2", "page": "2"}).exec()
require.Equal(t, http.StatusOK, sc.resp.Code)
var resp models.SearchOrgUsersQueryResult
err := json.Unmarshal(sc.resp.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Len(t, resp.OrgUsers, 1)
assert.Equal(t, int64(3), resp.TotalCount)
assert.Equal(t, 2, resp.PerPage)
assert.Equal(t, 2, resp.Page)
})
loggedInUserScenario(t, "When calling GET as an editor with no team / folder permissions on",
"api/org/users/lookup", func(sc *scenarioContext) {
setUpGetOrgUsersHandler()
+16
View File
@@ -114,6 +114,22 @@ type GetOrgUsersQuery struct {
Result []*OrgUserDTO
}
type SearchOrgUsersQuery struct {
OrgID int64
Query string
Page int
Limit int
Result SearchOrgUsersQueryResult
}
type SearchOrgUsersQueryResult struct {
TotalCount int64 `json:"totalCount"`
OrgUsers []*OrgUserDTO `json:"OrgUsers"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
// ----------------------
// Projections and DTOs
-115
View File
@@ -1,115 +0,0 @@
package load
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"testing"
"cuelang.org/go/cue"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana/pkg/schema"
"golang.org/x/tools/txtar"
)
var CasesDir = filepath.Join("testdata", "artifacts", "dashboards", "trimdefault")
type Case struct {
Name string
CUE string
Full string
Trimed string
}
func TestGenerate(t *testing.T) {
cases, err := loadCases(CasesDir)
if err != nil {
t.Fatal(err)
}
for _, c := range cases {
t.Run(c.Name+" apply default value", func(t *testing.T) {
var r cue.Runtime
scmInstance, err := r.Compile(c.Name+".cue", c.CUE)
if err != nil {
t.Fatal(err)
}
inputResource := schema.Resource{Value: c.Trimed}
scm := genericVersionedSchema{actual: scmInstance.Value()}
out, err := scm.ApplyDefaults(inputResource)
if err != nil {
t.Fatal(err)
}
b := []byte(out.Value.(string))
if s := cmp.Diff(string(b), c.Full); s != "" {
t.Fatal(s)
}
})
}
for _, c := range cases {
t.Run(c.Name+" trim default value", func(t *testing.T) {
var r cue.Runtime
scmInstance, err := r.Compile(c.Name+".cue", c.CUE)
if err != nil {
t.Fatal(err)
}
inputResource := schema.Resource{Value: c.Full}
scm := genericVersionedSchema{actual: scmInstance.Value()}
out, err := scm.TrimDefaults(inputResource)
if err != nil {
t.Fatal(err)
}
b := []byte(out.Value.(string))
if s := cmp.Diff(string(b), c.Trimed); s != "" {
t.Fatal(s)
}
})
}
}
func loadCases(dir string) ([]Case, error) {
files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
var cases []Case
for _, fi := range files {
file := filepath.Join(dir, fi.Name())
a, err := txtar.ParseFile(file)
if err != nil {
return nil, err
}
if len(a.Files) != 3 {
return nil, fmt.Errorf("Malformed test case '%s': Must contain exactly three files (CUE, Full and Trimed), but has %d", file, len(a.Files))
}
fullBuffer := new(bytes.Buffer)
fullJson := a.Files[1].Data
if err := json.Compact(fullBuffer, fullJson); err != nil {
return nil, err
}
trimBuffer := new(bytes.Buffer)
trimedJson := a.Files[2].Data
if err := json.Compact(trimBuffer, trimedJson); err != nil {
return nil, err
}
cases = append(cases, Case{
Name: fi.Name(),
CUE: string(a.Files[0].Data),
Full: fullBuffer.String(),
Trimed: trimBuffer.String(),
})
}
return cases, nil
}
-14
View File
@@ -129,20 +129,6 @@ func (cds *compositeDashboardSchema) Validate(r schema.Resource) error {
return cds.actual.Unify(rv.Value()).Validate(cue.Concrete(true))
}
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
func (cds *compositeDashboardSchema) ApplyDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
func (cds *compositeDashboardSchema) TrimDefaults(_ schema.Resource) (schema.Resource, error) {
panic("not implemented") // TODO: Implement
}
// CUE returns the cue.Value representing the actual schema.
func (cds *compositeDashboardSchema) CUE() cue.Value {
return cds.actual
-141
View File
@@ -1,13 +1,8 @@
package load
import (
"bytes"
"fmt"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/cue/load"
cuejson "cuelang.org/go/pkg/encoding/json"
"github.com/grafana/grafana/pkg/schema"
)
@@ -108,142 +103,6 @@ func (gvs *genericVersionedSchema) Validate(r schema.Resource) error {
return gvs.actual.Unify(rv.Value()).Validate(cue.Concrete(true))
}
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
func (gvs *genericVersionedSchema) ApplyDefaults(r schema.Resource) (schema.Resource, error) {
rv, err := rt.Compile("resource", r.Value)
if err != nil {
return r, err
}
rvUnified := rv.Value().Unify(gvs.CUE())
re, err := convertCUEValueToString(rvUnified)
if err != nil {
return r, err
}
return schema.Resource{Value: re}, nil
}
func convertCUEValueToString(inputCUE cue.Value) (string, error) {
re, err := cuejson.Marshal(inputCUE)
if err != nil {
return re, err
}
result := []byte(re)
result = bytes.Replace(result, []byte("\\u003c"), []byte("<"), -1)
result = bytes.Replace(result, []byte("\\u003e"), []byte(">"), -1)
result = bytes.Replace(result, []byte("\\u0026"), []byte("&"), -1)
return string(result), nil
}
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
func (gvs *genericVersionedSchema) TrimDefaults(r schema.Resource) (schema.Resource, error) {
rvInstance, err := rt.Compile("resource", r.Value)
if err != nil {
return r, err
}
rv, _, err := removeDefaultHelper(gvs.CUE(), rvInstance.Value())
if err != nil {
return r, err
}
re, err := convertCUEValueToString(rv)
fmt.Println("the trimed fields would be: ", re)
if err != nil {
return r, err
}
return schema.Resource{Value: re}, nil
}
func removeDefaultHelper(inputdef cue.Value, input cue.Value) (cue.Value, bool, error) {
// Since for now, panel definition is open validation,
// we need to loop on the input CUE for trimming
rvInstance, err := rt.Compile("resource", []byte{})
if err != nil {
return input, false, err
}
rv := rvInstance.Value()
switch inputdef.IncompleteKind() {
case cue.StructKind:
// Get all fields including optional fields
iter, err := inputdef.Fields(cue.Optional(true))
if err != nil {
return rv, false, err
}
keySet := make(map[string]bool)
for iter.Next() {
lable, _ := iter.Value().Label()
keySet[lable] = true
lv := input.LookupPath(cue.MakePath(cue.Str(lable)))
if err != nil {
continue
}
if lv.Exists() {
re, isEqual, err := removeDefaultHelper(iter.Value(), lv)
if err == nil && !isEqual {
rv = rv.FillPath(cue.MakePath(cue.Str(lable)), re)
}
}
}
// Get all the fields that are not defined in schema yet for panel
iter, err = input.Fields()
if err != nil {
return rv, false, err
}
for iter.Next() {
lable, _ := iter.Value().Label()
if exists := keySet[lable]; !exists {
rv = rv.FillPath(cue.MakePath(cue.Str(lable)), iter.Value())
}
}
return rv, false, nil
case cue.ListKind:
val, _ := inputdef.Default()
err1 := input.Subsume(val)
err2 := val.Subsume(input)
if val.IsConcrete() && err1 == nil && err2 == nil {
return rv, true, nil
}
ele := inputdef.LookupPath(cue.MakePath(cue.AnyIndex))
if ele.IncompleteKind() == cue.BottomKind {
return rv, true, nil
}
iter, err := input.List()
if err != nil {
return rv, true, nil
}
var iterlist []string
for iter.Next() {
re, isEqual, err := removeDefaultHelper(ele, iter.Value())
if err == nil && !isEqual {
reString, err := convertCUEValueToString(re)
if err != nil {
return rv, true, nil
}
iterlist = append(iterlist, reString)
}
}
iterlistContent := fmt.Sprintf("[%s]", strings.Join(iterlist, ","))
liInstance, err := rt.Compile("resource", []byte(iterlistContent))
if err != nil {
return rv, false, err
}
return liInstance.Value(), false, nil
default:
val, _ := inputdef.Default()
err1 := input.Subsume(val)
err2 := val.Subsume(input)
if val.IsConcrete() && err1 == nil && err2 == nil {
return input, true, nil
}
return input, false, nil
}
}
// CUE returns the cue.Value representing the actual schema.
func (gvs *genericVersionedSchema) CUE() cue.Value {
return gvs.actual
+1 -1
View File
@@ -51,7 +51,7 @@ func TestDashboardValidity(t *testing.T) {
// TODO FIXME remove this once we actually have dashboard schema filled in
// enough that the tests pass, lol
t.Skip()
validdir := os.DirFS(filepath.Join("testdata", "artifacts", "dashboards", "basic"))
validdir := os.DirFS(filepath.Join("testdata", "artifacts", "dashboards"))
dash, err := BaseDashboardFamily(p)
require.NoError(t, err, "error while loading base dashboard scuemata")
+141 -10
View File
@@ -1,13 +1,18 @@
package schema
import (
"bytes"
"errors"
"fmt"
"math/bits"
"strings"
"cuelang.org/go/cue"
cuejson "cuelang.org/go/pkg/encoding/json"
)
var rt = &cue.Runtime{}
// CueSchema represents a single, complete CUE-based schema that can perform
// operations on Resources.
//
@@ -23,16 +28,6 @@ type CueSchema interface {
// Validate checks that the resource is correct with respect to the schema.
Validate(Resource) error
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
ApplyDefaults(Resource) (Resource, error)
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
TrimDefaults(Resource) (Resource, error)
// Migrate transforms a Resource into a new Resource that is correct with
// respect to its Successor schema. It returns the transformed resource,
// the schema to which the resource now conforms, and any errors that
@@ -257,6 +252,142 @@ func Exact(maj, min int) SearchOption {
}
}
// ApplyDefaults returns a new, concrete copy of the Resource with all paths
// that are 1) missing in the Resource AND 2) specified by the schema,
// filled with default values specified by the schema.
func ApplyDefaults(r Resource, scue cue.Value) (Resource, error) {
rv, err := rt.Compile("resource", r.Value)
if err != nil {
return r, err
}
rvUnified := rv.Value().Unify(scue)
re, err := convertCUEValueToString(rvUnified)
if err != nil {
return r, err
}
return Resource{Value: re}, nil
}
func convertCUEValueToString(inputCUE cue.Value) (string, error) {
re, err := cuejson.Marshal(inputCUE)
if err != nil {
return re, err
}
result := []byte(re)
result = bytes.Replace(result, []byte("\\u003c"), []byte("<"), -1)
result = bytes.Replace(result, []byte("\\u003e"), []byte(">"), -1)
result = bytes.Replace(result, []byte("\\u0026"), []byte("&"), -1)
return string(result), nil
}
// TrimDefaults returns a new, concrete copy of the Resource where all paths
// in the where the values at those paths are the same as the default value
// given in the schema.
func TrimDefaults(r Resource, scue cue.Value) (Resource, error) {
rvInstance, err := rt.Compile("resource", r.Value)
if err != nil {
return r, err
}
rv, _, err := removeDefaultHelper(scue, rvInstance.Value())
if err != nil {
return r, err
}
re, err := convertCUEValueToString(rv)
if err != nil {
return r, err
}
return Resource{Value: re}, nil
}
func isCueValueEqual(inputdef cue.Value, input cue.Value) bool {
val, _ := inputdef.Default()
return input.Subsume(val) == nil && val.Subsume(input) == nil
}
func removeDefaultHelper(inputdef cue.Value, input cue.Value) (cue.Value, bool, error) {
// To include all optional fields, we need to use inputdef for iteration,
// since the lookuppath with optional field doesn't work very well
rvInstance, err := rt.Compile("resource", []byte{})
if err != nil {
return input, false, err
}
rv := rvInstance.Value()
switch inputdef.IncompleteKind() {
case cue.StructKind:
// Get all fields including optional fields
iter, err := inputdef.Fields(cue.Optional(true))
if err != nil {
return rv, false, err
}
keySet := make(map[string]bool)
for iter.Next() {
lable, _ := iter.Value().Label()
keySet[lable] = true
lv := input.LookupPath(cue.MakePath(cue.Str(lable)))
if err != nil {
continue
}
if lv.Exists() {
re, isEqual, err := removeDefaultHelper(iter.Value(), lv)
if err == nil && !isEqual {
rv = rv.FillPath(cue.MakePath(cue.Str(lable)), re)
}
}
}
// Get all the fields that are not defined in schema yet for panel
iter, err = input.Fields()
if err != nil {
return rv, false, err
}
for iter.Next() {
lable, _ := iter.Value().Label()
if exists := keySet[lable]; !exists {
rv = rv.FillPath(cue.MakePath(cue.Str(lable)), iter.Value())
}
}
return rv, false, nil
case cue.ListKind:
if isCueValueEqual(inputdef, input) {
return rv, true, nil
}
ele := inputdef.LookupPath(cue.MakePath(cue.AnyIndex))
if ele.IncompleteKind() == cue.BottomKind {
return rv, true, nil
}
iter, err := input.List()
if err != nil {
return rv, true, nil
}
// The following code is workaround since today overwrite list element doesn't work
var iterlist []string
for iter.Next() {
re, isEqual, err := removeDefaultHelper(ele, iter.Value())
if err == nil && !isEqual {
reString, err := convertCUEValueToString(re)
if err != nil {
return rv, true, nil
}
iterlist = append(iterlist, reString)
}
}
iterlistContent := fmt.Sprintf("[%s]", strings.Join(iterlist, ","))
liInstance, err := rt.Compile("resource", []byte(iterlistContent))
if err != nil {
return rv, false, err
}
return liInstance.Value(), false, nil
default:
if isCueValueEqual(inputdef, input) {
return input, true, nil
}
return input, false, nil
}
}
// A Resource represents a concrete data object - e.g., JSON
// representing a dashboard.
//
+106 -2
View File
@@ -1,4 +1,108 @@
package schema
// TODO tests for this stuff! Everything in this package is totally generic,
// nothing is specific to Grafana
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"testing"
"cuelang.org/go/cue"
"github.com/google/go-cmp/cmp"
"golang.org/x/tools/txtar"
)
var CasesDir = filepath.Join("testdata", "trimapplydefaults")
type Case struct {
Name string
CUE string
Full string
Trimmed string
}
func TestGenerate(t *testing.T) {
cases, err := loadCases(CasesDir)
if err != nil {
t.Fatal(err)
}
for _, c := range cases {
t.Run(c.Name+" apply default value", func(t *testing.T) {
var r cue.Runtime
scmInstance, err := r.Compile(c.Name+".cue", c.CUE)
if err != nil {
t.Fatal(err)
}
inputResource := Resource{Value: c.Trimmed}
out, err := ApplyDefaults(inputResource, scmInstance.Value())
if err != nil {
t.Fatal(err)
}
b := []byte(out.Value.(string))
if s := cmp.Diff(string(b), c.Full); s != "" {
t.Fatal(s)
}
})
}
for _, c := range cases {
t.Run(c.Name+" trim default value", func(t *testing.T) {
var r cue.Runtime
scmInstance, err := r.Compile(c.Name+".cue", c.CUE)
if err != nil {
t.Fatal(err)
}
inputResource := Resource{Value: c.Full}
out, err := TrimDefaults(inputResource, scmInstance.Value())
if err != nil {
t.Fatal(err)
}
b := []byte(out.Value.(string))
if s := cmp.Diff(string(b), c.Trimmed); s != "" {
t.Fatal(s)
}
})
}
}
func loadCases(dir string) ([]Case, error) {
files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
var cases []Case
for _, fi := range files {
file := filepath.Join(dir, fi.Name())
a, err := txtar.ParseFile(file)
if err != nil {
return nil, err
}
if len(a.Files) != 3 {
return nil, fmt.Errorf("Malformed test case '%s': Must contain exactly three files (CUE, Full and Trimed), but has %d", file, len(a.Files))
}
fullBuffer := new(bytes.Buffer)
fullJson := a.Files[1].Data
if err := json.Compact(fullBuffer, fullJson); err != nil {
return nil, err
}
trimBuffer := new(bytes.Buffer)
trimedJson := a.Files[2].Data
if err := json.Compact(trimBuffer, trimedJson); err != nil {
return nil, err
}
cases = append(cases, Case{
Name: fi.Name(),
CUE: string(a.Files[0].Data),
Full: fullBuffer.String(),
Trimmed: trimBuffer.String(),
})
}
return cases, nil
}
+4 -10
View File
@@ -117,22 +117,16 @@ func (am *ForkedAMSvc) RoutePostAlertingConfig(ctx *models.ReqContext, body apim
return response.Error(400, err.Error(), nil)
}
backendType, err := backendType(ctx, am.DatasourceCache)
b, err := backendType(ctx, am.DatasourceCache)
if err != nil {
return response.Error(400, err.Error(), nil)
}
payloadType := body.AlertmanagerConfig.Type()
if backendType != payloadType {
if err := body.AlertmanagerConfig.ReceiverType().MatchesBackend(b); err != nil {
return response.Error(
400,
fmt.Sprintf(
"unexpected backend type (%v) vs payload type (%v)",
backendType,
payloadType,
),
nil,
"bad match",
err,
)
}
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
@@ -381,6 +382,8 @@ func (c *GettableApiAlertingConfig) validate() error {
hasGrafReceivers = true
case AlertmanagerReceiverType:
hasAMReceivers = true
default:
continue
}
}
@@ -398,19 +401,6 @@ func (c *GettableApiAlertingConfig) validate() error {
return nil
}
// Type requires validate has been called and just checks the first receiver type
func (c *GettableApiAlertingConfig) Type() (backend Backend) {
for _, r := range c.Receivers {
switch r.Type() {
case GrafanaReceiverType:
return GrafanaBackend
case AlertmanagerReceiverType:
return AlertmanagerBackend
}
}
return
}
// Config is the top-level configuration for Alertmanager's config files.
type Config struct {
Global *config.GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"`
@@ -448,6 +438,8 @@ func (c *PostableApiAlertingConfig) validate() error {
hasGrafReceivers = true
case AlertmanagerReceiverType:
hasAMReceivers = true
default:
continue
}
}
@@ -466,22 +458,26 @@ func (c *PostableApiAlertingConfig) validate() error {
}
// Type requires validate has been called and just checks the first receiver type
func (c *PostableApiAlertingConfig) Type() (backend Backend) {
func (c *PostableApiAlertingConfig) ReceiverType() ReceiverType {
for _, r := range c.Receivers {
switch r.Type() {
case GrafanaReceiverType:
return GrafanaBackend
return GrafanaReceiverType
case AlertmanagerReceiverType:
return AlertmanagerBackend
return AlertmanagerReceiverType
default:
continue
}
}
return
return EmptyReceiverType
}
// AllReceivers will recursively walk a routing tree and return a list of all the
// referenced receiver names.
func AllReceivers(route *config.Route) (res []string) {
res = append(res, route.Receiver)
if route.Receiver != "" {
res = append(res, route.Receiver)
}
for _, subRoute := range route.Routes {
res = append(res, AllReceivers(subRoute)...)
}
@@ -494,10 +490,52 @@ type PostableGrafanaReceiver models.CreateAlertNotificationCommand
type ReceiverType int
const (
GrafanaReceiverType ReceiverType = iota
GrafanaReceiverType ReceiverType = 1 << iota
AlertmanagerReceiverType
EmptyReceiverType = GrafanaReceiverType | AlertmanagerReceiverType
)
func (r ReceiverType) String() string {
switch r {
case GrafanaReceiverType:
return "grafana"
case AlertmanagerReceiverType:
return "alertmanager"
case EmptyReceiverType:
return "empty"
default:
return "unknown"
}
}
// Can determines whether a receiver type can implement another receiver type.
// This is useful as receivers with just names but no contact points
// are valid in all backends.
func (r ReceiverType) Can(other ReceiverType) bool { return r&other != 0 }
// MatchesBackend determines if a config payload can be sent to a particular backend type
func (r ReceiverType) MatchesBackend(backend Backend) error {
msg := func(backend Backend, receiver ReceiverType) error {
return fmt.Errorf(
"unexpected backend type (%s) for receiver type (%s)",
backend.String(),
receiver.String(),
)
}
var ok bool
switch backend {
case GrafanaBackend:
ok = r.Can(GrafanaReceiverType)
case AlertmanagerBackend:
ok = r.Can(AlertmanagerReceiverType)
default:
}
if !ok {
return msg(backend, r)
}
return nil
}
type GettableApiReceiver struct {
config.Receiver `yaml:",inline"`
GettableGrafanaReceivers `yaml:",inline"`
@@ -554,25 +592,14 @@ type PostableApiReceiver struct {
}
func (r *PostableApiReceiver) UnmarshalYAML(unmarshal func(interface{}) error) error {
var grafanaReceivers PostableGrafanaReceivers
if err := unmarshal(&grafanaReceivers); err != nil {
if err := unmarshal(&r.PostableGrafanaReceivers); err != nil {
return err
}
r.PostableGrafanaReceivers = grafanaReceivers
var cfg config.Receiver
if err := unmarshal(&cfg); err != nil {
if err := unmarshal(&r.Receiver); err != nil {
return err
}
r.Name = cfg.Name
r.EmailConfigs = cfg.EmailConfigs
r.PagerdutyConfigs = cfg.PagerdutyConfigs
r.SlackConfigs = cfg.SlackConfigs
r.WebhookConfigs = cfg.WebhookConfigs
r.OpsGenieConfigs = cfg.OpsGenieConfigs
r.WechatConfigs = cfg.WechatConfigs
r.PushoverConfigs = cfg.PushoverConfigs
r.VictorOpsConfigs = cfg.VictorOpsConfigs
return nil
}
@@ -617,6 +644,13 @@ func (r *PostableApiReceiver) Type() ReceiverType {
if len(r.PostableGrafanaReceivers.GrafanaManagedReceivers) > 0 {
return GrafanaReceiverType
}
cpy := r.Receiver
cpy.Name = ""
if reflect.ValueOf(cpy).IsZero() {
return EmptyReceiverType
}
return AlertmanagerReceiverType
}
@@ -69,6 +69,50 @@ func Test_ApiReceiver_Marshaling(t *testing.T) {
}
}
func Test_APIReceiverType(t *testing.T) {
for _, tc := range []struct {
desc string
input PostableApiReceiver
expected ReceiverType
}{
{
desc: "empty",
input: PostableApiReceiver{
Receiver: config.Receiver{
Name: "foo",
},
},
expected: EmptyReceiverType,
},
{
desc: "am",
input: PostableApiReceiver{
Receiver: config.Receiver{
Name: "foo",
EmailConfigs: []*config.EmailConfig{{}},
},
},
expected: AlertmanagerReceiverType,
},
{
desc: "graf",
input: PostableApiReceiver{
Receiver: config.Receiver{
Name: "foo",
},
PostableGrafanaReceivers: PostableGrafanaReceivers{
GrafanaManagedReceivers: []*PostableGrafanaReceiver{{}},
},
},
expected: GrafanaReceiverType,
},
} {
t.Run(tc.desc, func(t *testing.T) {
require.Equal(t, tc.expected, tc.input.Type())
})
}
}
func Test_AllReceivers(t *testing.T) {
input := &config.Route{
Receiver: "foo",
@@ -88,6 +132,10 @@ func Test_AllReceivers(t *testing.T) {
}
require.Equal(t, []string{"foo", "bar", "bazz", "buzz"}, AllReceivers(input))
// test empty
var empty []string
require.Equal(t, empty, AllReceivers(&config.Route{}))
}
func Test_ApiAlertingConfig_Marshaling(t *testing.T) {
@@ -405,3 +453,113 @@ func Test_GettableUserConfigRoundtrip(t *testing.T) {
require.Nil(t, err)
require.Equal(t, string(yamlEncoded), string(out))
}
func Test_ReceiverCompatibility(t *testing.T) {
for _, tc := range []struct {
desc string
a, b ReceiverType
expected bool
}{
{
desc: "grafana=grafana",
a: GrafanaReceiverType,
b: GrafanaReceiverType,
expected: true,
},
{
desc: "am=am",
a: AlertmanagerReceiverType,
b: AlertmanagerReceiverType,
expected: true,
},
{
desc: "empty=grafana",
a: EmptyReceiverType,
b: AlertmanagerReceiverType,
expected: true,
},
{
desc: "empty=am",
a: EmptyReceiverType,
b: AlertmanagerReceiverType,
expected: true,
},
{
desc: "empty=empty",
a: EmptyReceiverType,
b: EmptyReceiverType,
expected: true,
},
{
desc: "graf!=am",
a: GrafanaReceiverType,
b: AlertmanagerReceiverType,
expected: false,
},
{
desc: "am!=graf",
a: AlertmanagerReceiverType,
b: GrafanaReceiverType,
expected: false,
},
} {
t.Run(tc.desc, func(t *testing.T) {
require.Equal(t, tc.expected, tc.a.Can(tc.b))
})
}
}
func Test_ReceiverMatchesBackend(t *testing.T) {
for _, tc := range []struct {
desc string
rec ReceiverType
b Backend
err bool
}{
{
desc: "graf=graf",
rec: GrafanaReceiverType,
b: GrafanaBackend,
err: false,
},
{
desc: "empty=graf",
rec: EmptyReceiverType,
b: GrafanaBackend,
err: false,
},
{
desc: "am=am",
rec: AlertmanagerReceiverType,
b: AlertmanagerBackend,
err: false,
},
{
desc: "empty=am",
rec: EmptyReceiverType,
b: AlertmanagerBackend,
err: false,
},
{
desc: "graf!=am",
rec: GrafanaReceiverType,
b: AlertmanagerBackend,
err: true,
},
{
desc: "am!=ruler",
rec: GrafanaReceiverType,
b: LoTexRulerBackend,
err: true,
},
} {
t.Run(tc.desc, func(t *testing.T) {
err := tc.rec.MatchesBackend(tc.b)
if tc.err {
require.NotNil(t, err)
} else {
require.Nil(t, err)
}
})
}
}
+7 -15
View File
@@ -7,8 +7,8 @@ import (
// AlertInstance represents a single alert instance.
type AlertInstance struct {
RuleOrgID int64 `xorm:"def_org_id"`
RuleUID string `xorm:"def_uid"`
RuleOrgID int64 `xorm:"rule_org_id"`
RuleUID string `xorm:"rule_uid"`
Labels InstanceLabels
LabelsHash string
CurrentState InstanceStateType
@@ -73,14 +73,10 @@ type ListAlertInstancesQuery struct {
Result []*ListAlertInstancesQueryResult
}
type FetchUniqueOrgIdsQuery struct {
Result []*FetchUniqueOrgIdsQueryResult
}
// ListAlertInstancesQueryResult represents the result of listAlertInstancesQuery.
type ListAlertInstancesQueryResult struct {
RuleOrgID int64 `xorm:"def_org_id" json:"definitionOrgId"`
RuleDefinitionUID string `xorm:"def_uid" json:"definitionUid"`
RuleOrgID int64 `xorm:"rule_org_id" json:"ruleOrgId"`
RuleUID string `xorm:"rule_uid" json:"ruleUid"`
Labels InstanceLabels `json:"labels"`
LabelsHash string `json:"labeHash"`
CurrentState InstanceStateType `json:"currentState"`
@@ -89,11 +85,7 @@ type ListAlertInstancesQueryResult struct {
LastEvalTime time.Time `json:"lastEvalTime"`
}
type FetchUniqueOrgIdsQueryResult struct {
DefinitionOrgID int64 `xorm:"def_org_id" json:"definitionOrgId"`
}
// ValidateAlertInstance validates that the alert instance contains an alert definition id,
// ValidateAlertInstance validates that the alert instance contains an alert rule id,
// and state.
func ValidateAlertInstance(alertInstance *AlertInstance) error {
if alertInstance == nil {
@@ -101,11 +93,11 @@ func ValidateAlertInstance(alertInstance *AlertInstance) error {
}
if alertInstance.RuleOrgID == 0 {
return fmt.Errorf("alert instance is invalid due to missing alert definition organisation")
return fmt.Errorf("alert instance is invalid due to missing alert rule organisation")
}
if alertInstance.RuleUID == "" {
return fmt.Errorf("alert instance is invalid due to missing alert definition uid")
return fmt.Errorf("alert instance is invalid due to missing alert rule uid")
}
if !alertInstance.CurrentState.IsValid() {
@@ -260,8 +260,6 @@ func (am *Alertmanager) SyncAndApplyConfigFromDatabase() error {
return nil
}
const defaultTemplate = "templates/default.tmpl"
// applyConfig applies a new configuration by re-initializing all components using the configuration provided.
// It is not safe to call concurrently.
func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) error {
@@ -279,6 +277,12 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig
if md5.Sum(am.config) != md5.Sum(rawConfig) {
configChanged = true
}
if cfg.TemplateFiles == nil {
cfg.TemplateFiles = map[string]string{}
}
cfg.TemplateFiles["__default__.tmpl"] = channels.DefaultTemplateString
// next, we need to make sure we persist the templates to disk.
paths, templatesChanged, err := PersistTemplates(cfg, am.WorkingDirPath())
if err != nil {
@@ -291,8 +295,6 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig
return nil
}
paths = append([]string{defaultTemplate}, paths...)
// With the templates persisted, create the template list using the paths.
tmpl, err := template.FromGlobs(paths...)
if err != nil {
@@ -0,0 +1,51 @@
package channels
import (
"io/ioutil"
"os"
"testing"
"github.com/prometheus/alertmanager/template"
"github.com/stretchr/testify/require"
)
const DefaultTemplateString = `
{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }}
{{ define "__text_alert_list" }}{{ range . }}Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}Source: {{ .GeneratorURL }}
{{ end }}{{ end }}
{{ define "default.title" }}{{ template "__subject" . }}{{ end }}
{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}
**Firing**
{{ template "__text_alert_list" .Alerts.Firing }}
{{ end }}
{{ if gt (len .Alerts.Resolved) 0 }}
**Resolved**
{{ template "__text_alert_list" .Alerts.Resolved }}
{{ end }}
{{ end }}
`
func templateForTests(t *testing.T) *template.Template {
f, err := ioutil.TempFile("/tmp", "template")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(f.Name()))
})
_, err = f.WriteString(DefaultTemplateString)
require.NoError(t, err)
tmpl, err := template.FromGlobs(f.Name())
require.NoError(t, err)
return tmpl
}
@@ -8,7 +8,6 @@ import (
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -20,8 +19,7 @@ import (
)
func TestDingdingNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
@@ -58,8 +58,8 @@ func NewPagerdutyNotifier(model *models.AlertNotification, t *template.Template)
NotifierBase: old_notifiers.NewNotifierBase(model),
Key: key,
CustomDetails: map[string]string{
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`,
"resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`,
"firing": `{{ template "__text_alert_list" .Alerts.Firing }}`,
"resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`,
"num_firing": `{{ .Alerts.Firing | len }}`,
"num_resolved": `{{ .Alerts.Resolved | len }}`,
},
@@ -67,7 +67,7 @@ func NewPagerdutyNotifier(model *models.AlertNotification, t *template.Template)
Class: model.Settings.Get("class").MustString("default"),
Component: model.Settings.Get("component").MustString("Grafana"),
Group: model.Settings.Get("group").MustString("default"),
Summary: model.Settings.Get("summary").MustString(`{{ template "pagerduty.default.description" .}}`),
Summary: model.Settings.Get("summary").MustString(`{{ template "default.title" . }}`),
tmpl: t,
log: log.New("alerting.notifier." + model.Name),
}, nil
@@ -9,7 +9,6 @@ import (
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -21,8 +20,7 @@ import (
)
func TestPagerdutyNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
@@ -121,8 +121,8 @@ func NewSlackNotifier(model *models.AlertNotification, t *template.Template) (*S
IconEmoji: model.Settings.Get("icon_emoji").MustString(),
IconURL: model.Settings.Get("icon_url").MustString(),
Token: token,
Text: model.Settings.Get("text").MustString(`{{ template "slack.default.text" . }}`),
Title: model.Settings.Get("title").MustString(`{{ template "slack.default.title" . }}`),
Text: model.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
Title: model.Settings.Get("title").MustString(`{{ template "default.title" . }}`),
log: log.New("alerting.notifier.slack"),
tmpl: t,
}, nil
@@ -10,7 +10,6 @@ import (
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -22,8 +21,7 @@ import (
)
func TestSlackNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
@@ -60,7 +58,7 @@ func TestSlackNotifier(t *testing.T) {
{
Title: "[FIRING:1] (val1)",
TitleLink: "http:/localhost/alerting/list",
Text: "",
Text: "\n**Firing**\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: \n\n\n\n\n",
Fallback: "[FIRING:1] (val1)",
Fields: nil,
Footer: "Grafana v",
@@ -96,7 +94,7 @@ func TestSlackNotifier(t *testing.T) {
{
Title: "[FIRING:1] (val1)",
TitleLink: "http:/localhost/alerting/list",
Text: "",
Text: "\n**Firing**\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: \n\n\n\n\n",
Fallback: "[FIRING:1] (val1)",
Fields: nil,
Footer: "Grafana v",
@@ -139,7 +137,7 @@ func TestSlackNotifier(t *testing.T) {
{
Title: "2 firing, 0 resolved",
TitleLink: "http:/localhost/alerting/list",
Text: "",
Text: "\n**Firing**\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: \nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSource: \n\n\n\n\n",
Fallback: "2 firing, 0 resolved",
Fields: nil,
Footer: "Grafana v",
@@ -8,7 +8,6 @@ import (
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -20,8 +19,7 @@ import (
)
func TestTeamsNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -18,8 +17,7 @@ import (
)
func TestTelegramNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
@@ -1,230 +0,0 @@
{{ define "__alertmanager" }}Alertmanager{{ end }}
{{ define "__alertmanagerURL" }}{{ .ExternalURL }}/#/alerts?receiver={{ .Receiver | urlquery }}{{ end }}
{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }}
{{ define "__description" }}{{ end }}
{{ define "__text_alert_list" }}{{ range . }}Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}Source: {{ .GeneratorURL }}
{{ end }}{{ end }}
{{ define "default.title" }}{{ template "__subject" . }}{{ end }}
{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}
**Firing**
{{ template "__text_alert_list" .Alerts.Firing }}
{{ end }}
{{ if gt (len .Alerts.Resolved) 0 }}
**Resolved**
{{ template "__text_alert_list" .Alerts.Resolved }}
{{ end }}
{{ end }}
{{ define "slack.default.title" }}{{ template "default.title" . }}{{ end }}
{{ define "slack.default.username" }}{{ template "__alertmanager" . }}{{ end }}
{{ define "slack.default.fallback" }}{{ template "slack.default.title" . }} | {{ template "slack.default.titlelink" . }}{{ end }}
{{ define "slack.default.callbackid" }}{{ end }}
{{ define "slack.default.pretext" }}{{ end }}
{{ define "slack.default.titlelink" }}{{ template "__alertmanagerURL" . }}{{ end }}
{{ define "slack.default.iconemoji" }}{{ end }}
{{ define "slack.default.iconurl" }}{{ end }}
{{ define "slack.default.text" }}{{ end }}
{{ define "slack.default.footer" }}{{ end }}
{{ define "pagerduty.default.description" }}{{ template "default.title" . }}{{ end }}
{{ define "pagerduty.default.client" }}{{ template "__alertmanager" . }}{{ end }}
{{ define "pagerduty.default.clientURL" }}{{ template "__alertmanagerURL" . }}{{ end }}
{{ define "pagerduty.default.instances" }}{{ template "__text_alert_list" . }}{{ end }}
{{ define "opsgenie.default.message" }}{{ template "__subject" . }}{{ end }}
{{ define "opsgenie.default.description" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }}
{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ template "__text_alert_list" .Alerts.Firing }}
{{- end }}
{{ if gt (len .Alerts.Resolved) 0 -}}
Alerts Resolved:
{{ template "__text_alert_list" .Alerts.Resolved }}
{{- end }}
{{- end }}
{{ define "opsgenie.default.source" }}{{ template "__alertmanagerURL" . }}{{ end }}
{{ define "wechat.default.message" }}{{ template "__subject" . }}
{{ .CommonAnnotations.SortedPairs.Values | join " " }}
{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ template "__text_alert_list" .Alerts.Firing }}
{{- end }}
{{ if gt (len .Alerts.Resolved) 0 -}}
Alerts Resolved:
{{ template "__text_alert_list" .Alerts.Resolved }}
{{- end }}
AlertmanagerUrl:
{{ template "__alertmanagerURL" . }}
{{- end }}
{{ define "wechat.default.to_user" }}{{ end }}
{{ define "wechat.default.to_party" }}{{ end }}
{{ define "wechat.default.to_tag" }}{{ end }}
{{ define "wechat.default.agent_id" }}{{ end }}
{{ define "victorops.default.state_message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }}
{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ template "__text_alert_list" .Alerts.Firing }}
{{- end }}
{{ if gt (len .Alerts.Resolved) 0 -}}
Alerts Resolved:
{{ template "__text_alert_list" .Alerts.Resolved }}
{{- end }}
{{- end }}
{{ define "victorops.default.entity_display_name" }}{{ template "__subject" . }}{{ end }}
{{ define "victorops.default.monitoring_tool" }}{{ template "__alertmanager" . }}{{ end }}
{{ define "email.default.subject" }}{{ template "__subject" . }}{{ end }}
{{ define "email.default.html" }}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--
Style and HTML derived from https://github.com/mailgun/transactional-email-templates
The MIT License (MIT)
Copyright (c) 2014 Mailgun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<head style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<meta name="viewport" content="width=device-width" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
<title style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">{{ template "__subject" . }}</title>
</head>
<body itemscope="" itemtype="http://schema.org/EmailMessage" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; height: 100%; line-height: 1.6em; width: 100% !important; background-color: #f6f6f6; margin: 0; padding: 0;" bgcolor="#f6f6f6">
<table style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
<td width="600" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; width: 100% !important; margin: 0 auto; padding: 0;" valign="top">
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; max-width: 600px; display: block; margin: 0 auto; padding: 0;">
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff">
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; vertical-align: top; color: #fff; font-weight: 500; text-align: center; border-radius: 3px 3px 0 0; background-color: #E6522C; margin: 0; padding: 20px;" align="center" bgcolor="#E6522C" valign="top">
{{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }}
{{ .Name }}={{ .Value }}
{{ end }}
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 10px;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{ template "__alertmanagerURL" . }}" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2em; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; text-transform: capitalize; background-color: #348eda; margin: 0; border-color: #348eda; border-style: solid; border-width: 10px 20px;">View in {{ template "__alertmanager" . }}</a>
</td>
</tr>
{{ if gt (len .Alerts.Firing) 0 }}
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">[{{ .Alerts.Firing | len }}] Firing</strong>
</td>
</tr>
{{ end }}
{{ range .Alerts.Firing }}
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">Labels</strong><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
{{ if gt (len .Annotations) 0 }}<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">Annotations</strong><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
{{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
<a href="{{ .GeneratorURL }}" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #348eda; text-decoration: underline; margin: 0;">Source</a><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
</td>
</tr>
{{ end }}
{{ if gt (len .Alerts.Resolved) 0 }}
{{ if gt (len .Alerts.Firing) 0 }}
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
<hr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
</td>
</tr>
{{ end }}
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">[{{ .Alerts.Resolved | len }}] Resolved</strong>
</td>
</tr>
{{ end }}
{{ range .Alerts.Resolved }}
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">Labels</strong><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
{{ if gt (len .Annotations) 0 }}<strong style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">Annotations</strong><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
{{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}<br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />{{ end }}
<a href="{{ .GeneratorURL }}" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #348eda; text-decoration: underline; margin: 0;">Source</a><br style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
</td>
</tr>
{{ end }}
</table>
</td>
</tr>
</table>
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">
<table width="100%" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<tr style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; text-align: center; color: #999; margin: 0; padding: 0 0 20px;" align="center" valign="top"><a href="{{ .ExternalURL }}" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 12px; color: #999; text-decoration: underline; margin: 0;">Sent by {{ template "__alertmanager" . }}</a></td>
</tr>
</table>
</div></div>
</td>
<td style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top"></td>
</tr>
</table>
</body>
</html>
{{ end }}
{{ define "pushover.default.title" }}{{ template "__subject" . }}{{ end }}
{{ define "pushover.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }}
{{ if gt (len .Alerts.Firing) 0 }}
Alerts Firing:
{{ template "__text_alert_list" .Alerts.Firing }}
{{ end }}
{{ if gt (len .Alerts.Resolved) 0 }}
Alerts Resolved:
{{ template "__text_alert_list" .Alerts.Resolved }}
{{ end }}
{{ end }}
{{ define "pushover.default.url" }}{{ template "__alertmanagerURL" . }}{{ end }}
@@ -19,8 +19,7 @@ import (
)
func TestWebhookNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
+17 -14
View File
@@ -305,16 +305,19 @@ func (sch *schedule) Ticker(grafanaCtx context.Context, stateManager *state.Mana
sch.registry.del(key)
}
case <-grafanaCtx.Done():
err := dispatcherGroup.Wait()
orgIdsCmd := models.FetchUniqueOrgIdsQuery{}
if err := sch.instanceStore.FetchOrgIds(&orgIdsCmd); err != nil {
waitErr := dispatcherGroup.Wait()
orgIds, err := sch.instanceStore.FetchOrgIds()
if err != nil {
sch.log.Error("unable to fetch orgIds", "msg", err.Error())
}
for _, v := range orgIdsCmd.Result {
sch.saveAlertStates(stateManager.GetAll(v.DefinitionOrgID))
for _, v := range orgIds {
sch.saveAlertStates(stateManager.GetAll(v))
}
stateManager.Close()
return err
return waitErr
}
}
}
@@ -346,16 +349,16 @@ func (sch *schedule) WarmStateCache(st *state.Manager) {
sch.log.Info("warming cache for startup")
st.ResetCache()
orgIdsCmd := models.FetchUniqueOrgIdsQuery{}
if err := sch.instanceStore.FetchOrgIds(&orgIdsCmd); err != nil {
orgIds, err := sch.instanceStore.FetchOrgIds()
if err != nil {
sch.log.Error("unable to fetch orgIds", "msg", err.Error())
}
var states []*state.State
for _, orgIdResult := range orgIdsCmd.Result {
for _, orgId := range orgIds {
// Get Rules
ruleCmd := models.ListAlertRulesQuery{
OrgID: orgIdResult.DefinitionOrgID,
OrgID: orgId,
}
if err := sch.ruleStore.GetOrgAlertRules(&ruleCmd); err != nil {
sch.log.Error("unable to fetch previous state", "msg", err.Error())
@@ -368,16 +371,16 @@ func (sch *schedule) WarmStateCache(st *state.Manager) {
// Get Instances
cmd := models.ListAlertInstancesQuery{
RuleOrgID: orgIdResult.DefinitionOrgID,
RuleOrgID: orgId,
}
if err := sch.instanceStore.ListAlertInstances(&cmd); err != nil {
sch.log.Error("unable to fetch previous state", "msg", err.Error())
}
for _, entry := range cmd.Result {
ruleForEntry, ok := ruleByUID[entry.RuleDefinitionUID]
ruleForEntry, ok := ruleByUID[entry.RuleUID]
if !ok {
sch.log.Error("rule not found for instance, ignoring", "rule", entry.RuleDefinitionUID)
sch.log.Error("rule not found for instance, ignoring", "rule", entry.RuleUID)
continue
}
@@ -387,7 +390,7 @@ func (sch *schedule) WarmStateCache(st *state.Manager) {
sch.log.Error("error getting cacheId for entry", "msg", err.Error())
}
stateForEntry := &state.State{
AlertRuleUID: entry.RuleDefinitionUID,
AlertRuleUID: entry.RuleUID,
OrgID: entry.RuleOrgID,
CacheId: cacheId,
Labels: lbs,
+4 -4
View File
@@ -80,7 +80,7 @@ func (st DBstore) DeleteAlertRuleByUID(orgID int64, ruleUID string) error {
return err
}
_, err = sess.Exec("DELETE FROM alert_instance WHERE def_org_id = ? AND def_uid = ?", orgID, ruleUID)
_, err = sess.Exec("DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid = ?", orgID, ruleUID)
if err != nil {
return err
}
@@ -109,7 +109,7 @@ func (st DBstore) DeleteNamespaceAlertRules(orgID int64, namespaceUID string) ([
return err
}
if _, err := sess.Exec(`DELETE FROM alert_instance WHERE def_org_id = ? AND def_uid NOT IN (
if _, err := sess.Exec(`DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid NOT IN (
SELECT uid FROM alert_rule where org_id = ?
)`, orgID, orgID); err != nil {
return err
@@ -146,7 +146,7 @@ func (st DBstore) DeleteRuleGroupAlertRules(orgID int64, namespaceUID string, ru
return err
}
if _, err := sess.Exec(`DELETE FROM alert_instance WHERE def_org_id = ? AND def_uid NOT IN (
if _, err := sess.Exec(`DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid NOT IN (
SELECT uid FROM alert_rule where org_id = ?
)`, orgID, orgID); err != nil {
return err
@@ -161,7 +161,7 @@ func (st DBstore) DeleteRuleGroupAlertRules(orgID int64, namespaceUID string, ru
// DeleteAlertInstanceByRuleUID is a handler for deleting alert instances by alert rule UID when a rule has been updated
func (st DBstore) DeleteAlertInstancesByRuleUID(orgID int64, ruleUID string) error {
return st.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
_, err := sess.Exec("DELETE FROM alert_instance WHERE def_org_id = ? AND def_uid = ?", orgID, ruleUID)
_, err := sess.Exec("DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid = ?", orgID, ruleUID)
if err != nil {
return err
}
-8
View File
@@ -14,14 +14,6 @@ var TimeNow = time.Now
// AlertDefinitionMaxTitleLength is the maximum length of the alert definition title
const AlertDefinitionMaxTitleLength = 190
// Store is the interface for persisting alert definitions and instances
type Store interface {
GetAlertInstance(*models.GetAlertInstanceQuery) error
ListAlertInstances(*models.ListAlertInstancesQuery) error
SaveAlertInstance(*models.SaveAlertInstanceCommand) error
FetchOrgIds(cmd *models.FetchUniqueOrgIdsQuery) error
}
// AlertingStore is the database interface used by the Alertmanager service.
type AlertingStore interface {
GetLatestAlertmanagerConfiguration(*models.GetLatestAlertmanagerConfigurationQuery) error
@@ -52,6 +52,8 @@ func AddAlertDefinitionMigrations(mg *migrator.Migrator, defaultIntervalSeconds
mg.AddMigration("Add column paused in alert_definition", migrator.NewAddColumnMigration(alertDefinition, &migrator.Column{
Name: "paused", Type: migrator.DB_Bool, Nullable: false, Default: "0",
}))
mg.AddMigration("drop alert_definition table", migrator.NewDropTableMigration("alert_definition"))
}
// AddAlertDefinitionMigrations should not be modified.
@@ -84,6 +86,7 @@ func AddAlertDefinitionVersionMigrations(mg *migrator.Migrator) {
mg.AddMigration("alter alert_definition_version table data column to mediumtext in mysql", migrator.NewRawSQLMigration("").
Mysql("ALTER TABLE alert_definition_version MODIFY data MEDIUMTEXT;"))
mg.AddMigration("drop alert_definition_version table", migrator.NewDropTableMigration("alert_definition_version"))
}
func AlertInstanceMigration(mg *migrator.Migrator) {
@@ -112,6 +115,24 @@ func AlertInstanceMigration(mg *migrator.Migrator) {
mg.AddMigration("add column current_state_end to alert_instance", migrator.NewAddColumnMigration(alertInstance, &migrator.Column{
Name: "current_state_end", Type: migrator.DB_BigInt, Nullable: false, Default: "0",
}))
mg.AddMigration("remove index def_org_id, def_uid, current_state on alert_instance", migrator.NewDropIndexMigration(alertInstance, alertInstance.Indices[0]))
mg.AddMigration("remove index def_org_id, current_state on alert_instance", migrator.NewDropIndexMigration(alertInstance, alertInstance.Indices[1]))
mg.AddMigration("rename def_org_id to rule_org_id in alert_instance", migrator.NewRawSQLMigration("").
Default("ALTER TABLE alert_instance RENAME COLUMN def_org_id TO rule_org_id;").
Mysql("ALTER TABLE alert_instance CHANGE def_org_id rule_org_id BIGINT;"))
mg.AddMigration("rename def_uid to rule_uid in alert_instance", migrator.NewRawSQLMigration("").
Default("ALTER TABLE alert_instance RENAME COLUMN def_uid TO rule_uid;").
Mysql("ALTER TABLE alert_instance CHANGE def_uid rule_uid VARCHAR(40);"))
mg.AddMigration("add index rule_org_id, rule_uid, current_state on alert_instance", migrator.NewAddIndexMigration(alertInstance, &migrator.Index{
Cols: []string{"rule_org_id", "rule_uid", "current_state"}, Type: migrator.IndexType,
}))
mg.AddMigration("add index rule_org_id, current_state on alert_instance", migrator.NewAddIndexMigration(alertInstance, &migrator.Index{
Cols: []string{"rule_org_id", "current_state"}, Type: migrator.IndexType,
}))
}
func AddAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) {
+14 -14
View File
@@ -13,7 +13,7 @@ type InstanceStore interface {
GetAlertInstance(cmd *models.GetAlertInstanceQuery) error
ListAlertInstances(cmd *models.ListAlertInstancesQuery) error
SaveAlertInstance(cmd *models.SaveAlertInstanceCommand) error
FetchOrgIds(cmd *models.FetchUniqueOrgIdsQuery) error
FetchOrgIds() ([]int64, error)
}
// GetAlertInstance is a handler for retrieving an alert instance based on OrgId, AlertDefintionID, and
@@ -24,8 +24,8 @@ func (st DBstore) GetAlertInstance(cmd *models.GetAlertInstanceQuery) error {
s := strings.Builder{}
s.WriteString(`SELECT * FROM alert_instance
WHERE
def_org_id=? AND
def_uid=? AND
rule_org_id=? AND
rule_uid=? AND
labels_hash=?
`)
@@ -38,7 +38,7 @@ func (st DBstore) GetAlertInstance(cmd *models.GetAlertInstanceQuery) error {
has, err := sess.SQL(s.String(), params...).Get(&instance)
if !has {
return fmt.Errorf("instance not found for labels %v (hash: %v), alert definition %v (org %v)", cmd.Labels, hash, cmd.RuleUID, cmd.RuleOrgID)
return fmt.Errorf("instance not found for labels %v (hash: %v), alert rule %v (org %v)", cmd.Labels, hash, cmd.RuleUID, cmd.RuleOrgID)
}
if err != nil {
return err
@@ -63,10 +63,10 @@ func (st DBstore) ListAlertInstances(cmd *models.ListAlertInstancesQuery) error
params = append(params, p...)
}
addToQuery("SELECT alert_instance.*, alert_definition.title AS def_title FROM alert_instance LEFT JOIN alert_definition ON alert_instance.def_org_id = alert_definition.org_id AND alert_instance.def_uid = alert_definition.uid WHERE def_org_id = ?", cmd.RuleOrgID)
addToQuery("SELECT alert_instance.*, alert_rule.title AS rule_title FROM alert_instance LEFT JOIN alert_rule ON alert_instance.rule_org_id = alert_rule.org_id AND alert_instance.rule_uid = alert_rule.uid WHERE rule_org_id = ?", cmd.RuleOrgID)
if cmd.RuleUID != "" {
addToQuery(` AND def_uid = ?`, cmd.RuleUID)
addToQuery(` AND rule_uid = ?`, cmd.RuleUID)
}
if cmd.State != "" {
@@ -109,8 +109,8 @@ func (st DBstore) SaveAlertInstance(cmd *models.SaveAlertInstanceCommand) error
upsertSQL := st.SQLStore.Dialect.UpsertSQL(
"alert_instance",
[]string{"def_org_id", "def_uid", "labels_hash"},
[]string{"def_org_id", "def_uid", "labels", "labels_hash", "current_state", "current_state_since", "current_state_end", "last_eval_time"})
[]string{"rule_org_id", "rule_uid", "labels_hash"},
[]string{"rule_org_id", "rule_uid", "labels", "labels_hash", "current_state", "current_state_since", "current_state_end", "last_eval_time"})
_, err = sess.SQL(upsertSQL, params...).Query()
if err != nil {
return err
@@ -120,10 +120,10 @@ func (st DBstore) SaveAlertInstance(cmd *models.SaveAlertInstanceCommand) error
})
}
func (st DBstore) FetchOrgIds(cmd *models.FetchUniqueOrgIdsQuery) error {
return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
orgIds := make([]*models.FetchUniqueOrgIdsQueryResult, 0)
func (st DBstore) FetchOrgIds() ([]int64, error) {
orgIds := []int64{}
err := st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
s := strings.Builder{}
params := make([]interface{}, 0)
@@ -132,13 +132,13 @@ func (st DBstore) FetchOrgIds(cmd *models.FetchUniqueOrgIdsQuery) error {
params = append(params, p...)
}
addToQuery("SELECT DISTINCT def_org_id FROM alert_instance")
addToQuery("SELECT DISTINCT rule_org_id FROM alert_instance")
if err := sess.SQL(s.String(), params...).Find(&orgIds); err != nil {
return err
}
cmd.Result = orgIds
return nil
})
return orgIds, err
}
@@ -172,7 +172,7 @@ func TestAlertInstanceOperations(t *testing.T) {
require.Len(t, listQuery.Result, 1)
require.Equal(t, saveCmdTwo.RuleOrgID, listQuery.Result[0].RuleOrgID)
require.Equal(t, saveCmdTwo.RuleUID, listQuery.Result[0].RuleDefinitionUID)
require.Equal(t, saveCmdTwo.RuleUID, listQuery.Result[0].RuleUID)
require.Equal(t, saveCmdTwo.Labels, listQuery.Result[0].Labels)
require.Equal(t, saveCmdTwo.State, listQuery.Result[0].CurrentState)
})
+2 -2
View File
@@ -62,7 +62,7 @@ func (rs *SchemaLoaderService) DashboardApplyDefaults(input *simplejson.Json) (*
val = removeNils(val)
data, _ := json.Marshal(val)
dsSchema := schema.Find(rs.DashFamily, schema.Latest())
result, err := dsSchema.ApplyDefaults(schema.Resource{Value: data})
result, err := schema.ApplyDefaults(schema.Resource{Value: data}, dsSchema.CUE())
if err != nil {
return input, err
}
@@ -83,7 +83,7 @@ func (rs *SchemaLoaderService) DashboardTrimDefaults(input simplejson.Json) (sim
return input, err
}
// spew.Dump(dsSchema)
result, err := dsSchema.TrimDefaults(schema.Resource{Value: data})
result, err := schema.TrimDefaults(schema.Resource{Value: data}, dsSchema.CUE())
if err != nil {
return input, err
}
+3 -2
View File
@@ -47,8 +47,9 @@ func (acs *AnnotationCleanupService) CleanAnnotations(ctx context.Context, cfg *
if err != nil {
return totalCleanedAnnotations, 0, err
}
affected, err = acs.cleanOrphanedAnnotationTags(ctx)
if totalCleanedAnnotations > 0 {
affected, err = acs.cleanOrphanedAnnotationTags(ctx)
}
return totalCleanedAnnotations, affected, err
}
+37
View File
@@ -95,6 +95,43 @@ func TestAccountDataAccess(t *testing.T) {
})
})
Convey("Given single org and 2 users inserted", func() {
setting.AutoAssignOrg = true
setting.AutoAssignOrgId = 1
setting.AutoAssignOrgRole = "Viewer"
ac1cmd := models.CreateUserCommand{Login: "ac1", Email: "ac1@test.com", Name: "ac1 name"}
ac2cmd := models.CreateUserCommand{Login: "ac2", Email: "ac2@test.com", Name: "ac2 name"}
ac1, err := sqlStore.CreateUser(context.Background(), ac1cmd)
So(err, ShouldBeNil)
_, err = sqlStore.CreateUser(context.Background(), ac2cmd)
So(err, ShouldBeNil)
Convey("Can get organization users paginated with query", func() {
query := models.SearchOrgUsersQuery{
OrgID: ac1.OrgId,
Page: 1,
}
err = sqlStore.SearchOrgUsers(&query)
So(err, ShouldBeNil)
So(len(query.Result.OrgUsers), ShouldEqual, 2)
})
Convey("Can get organization users paginated and limited", func() {
query := models.SearchOrgUsersQuery{
OrgID: ac1.OrgId,
Limit: 1,
Page: 1,
}
err = sqlStore.SearchOrgUsers(&query)
So(err, ShouldBeNil)
So(len(query.Result.OrgUsers), ShouldEqual, 1)
})
})
Convey("Given two saved users", func() {
setting.AutoAssignOrg = false
+65
View File
@@ -142,6 +142,71 @@ func GetOrgUsers(query *models.GetOrgUsersQuery) error {
return nil
}
func (ss *SQLStore) SearchOrgUsers(query *models.SearchOrgUsersQuery) error {
query.Result = models.SearchOrgUsersQueryResult{
OrgUsers: make([]*models.OrgUserDTO, 0),
}
sess := x.Table("org_user")
sess.Join("INNER", x.Dialect().Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", x.Dialect().Quote("user")))
whereConditions := make([]string, 0)
whereParams := make([]interface{}, 0)
whereConditions = append(whereConditions, "org_user.org_id = ?")
whereParams = append(whereParams, query.OrgID)
if query.Query != "" {
queryWithWildcards := "%" + query.Query + "%"
whereConditions = append(whereConditions, "(email "+dialect.LikeStr()+" ? OR name "+dialect.LikeStr()+" ? OR login "+dialect.LikeStr()+" ?)")
whereParams = append(whereParams, queryWithWildcards, queryWithWildcards, queryWithWildcards)
}
if len(whereConditions) > 0 {
sess.Where(strings.Join(whereConditions, " AND "), whereParams...)
}
if query.Limit > 0 {
offset := query.Limit * (query.Page - 1)
sess.Limit(query.Limit, offset)
}
sess.Cols(
"org_user.org_id",
"org_user.user_id",
"user.email",
"user.name",
"user.login",
"org_user.role",
"user.last_seen_at",
)
sess.Asc("user.email", "user.login")
if err := sess.Find(&query.Result.OrgUsers); err != nil {
return err
}
// get total count
orgUser := models.OrgUser{}
countSess := x.Table("org_user")
if len(whereConditions) > 0 {
countSess.Where(strings.Join(whereConditions, " AND "), whereParams...)
}
count, err := countSess.Count(&orgUser)
if err != nil {
return err
}
query.Result.TotalCount = count
for _, user := range query.Result.OrgUsers {
user.LastSeenAtAge = util.GetAgeString(user.LastSeenAt)
}
return nil
}
func RemoveOrgUser(cmd *models.RemoveOrgUserCommand) error {
return inTransaction(func(sess *DBSession) error {
// check if user exists
+9
View File
@@ -3,6 +3,7 @@ package loki
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
@@ -53,6 +54,11 @@ func (e *LokiExecutor) DataQuery(ctx context.Context, dsInfo *models.DataSource,
return plugins.DataResponse{}, err
}
transport, err := dsInfo.GetHttpTransport()
if err != nil {
return plugins.DataResponse{}, err
}
client := &client.DefaultClient{
Address: dsInfo.Url,
Username: dsInfo.BasicAuthUser,
@@ -60,6 +66,9 @@ func (e *LokiExecutor) DataQuery(ctx context.Context, dsInfo *models.DataSource,
TLSConfig: config.TLSConfig{
InsecureSkipVerify: tlsConfig.InsecureSkipVerify,
},
Tripperware: func(t http.RoundTripper) http.RoundTripper {
return transport
},
}
queries, err := e.parseQuery(dsInfo, queryContext)
@@ -85,19 +85,19 @@ describe('AngularLocationWrapper', () => {
it('search() should accept object', function () {
locationService.push('/path/b');
wrapper.search({ one: 1, two: true });
expect(wrapper.search()).toEqual({ one: 1, two: true });
wrapper.search({ one: '1', two: true });
expect(wrapper.search()).toEqual({ one: '1', two: true });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two');
});
it('should copy object', function () {
locationService.push('/path/b');
const obj: Record<string, any> = { one: 1, two: true, three: null };
const obj: Record<string, any> = { one: '1', two: true, three: null };
wrapper.search(obj);
expect(obj).toEqual({ one: 1, two: true, three: null });
expect(obj).toEqual({ one: '1', two: true, three: null });
obj.one = 'changed';
expect(wrapper.search()).toEqual({ one: 1, two: true });
expect(wrapper.search()).toEqual({ one: '1', two: true });
expect(wrapper.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two');
});
@@ -117,8 +117,8 @@ describe('AngularLocationWrapper', () => {
it('should remove multiple parameters', function () {
locationService.push('/path/b');
wrapper.search({ one: 1, two: true });
expect(wrapper.search()).toEqual({ one: 1, two: true });
wrapper.search({ one: '1', two: true });
expect(wrapper.search()).toEqual({ one: '1', two: true });
wrapper.search({ one: null, two: null });
expect(wrapper.search()).toEqual({});
@@ -36,7 +36,7 @@ export class OrgPicker extends PureComponent<Props, State> {
}
getOrgOptions = async (query: string): Promise<Array<SelectableValue<number>>> => {
if (!this.orgs) {
if (!this.orgs?.length) {
await this.loadOrgs();
}
return this.orgs.map(
+5 -1
View File
@@ -30,6 +30,7 @@ import {
AbsoluteTimeRange,
sortInAscendingOrder,
rangeUtil,
DataQuery,
} from '@grafana/data';
import { getThemeColor } from 'app/core/utils/colors';
import { config } from '@grafana/runtime';
@@ -202,7 +203,8 @@ export function dataFrameToLogsModel(
dataFrame: DataFrame[],
intervalMs: number | undefined,
timeZone: TimeZone,
absoluteRange?: AbsoluteTimeRange
absoluteRange?: AbsoluteTimeRange,
queries?: DataQuery[]
): LogsModel {
const { logSeries } = separateLogsAndMetrics(dataFrame);
const logsModel = logSeriesToLogsModel(logSeries);
@@ -225,6 +227,7 @@ export function dataFrameToLogsModel(
} else {
logsModel.series = [];
}
logsModel.queries = queries;
return logsModel;
}
@@ -233,6 +236,7 @@ export function dataFrameToLogsModel(
rows: [],
meta: [],
series: [],
queries,
};
}
+6 -16
View File
@@ -1,16 +1,6 @@
import React, { PureComponent } from 'react';
import { css, cx } from '@emotion/css';
import {
Button,
ConfirmButton,
Container,
Field,
HorizontalGroup,
Modal,
stylesFactory,
Themeable,
withTheme,
} from '@grafana/ui';
import { Button, ConfirmButton, Field, HorizontalGroup, Modal, stylesFactory, Themeable, withTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { AccessControlAction, Organization, OrgRole, UserOrg } from 'app/types';
import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
@@ -251,16 +241,16 @@ export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgMod
<Field label="Role">
<OrgRolePicker value={role} onChange={this.onOrgRoleChange} />
</Field>
<Container padding="md">
<Modal.ButtonRow>
<HorizontalGroup spacing="md" justify="center">
<Button variant="secondary" fill="outline" onClick={this.onCancel}>
Cancel
</Button>
<Button variant="primary" onClick={this.onAddUserToOrg}>
Add to organization
</Button>
<Button variant="secondary" onClick={this.onCancel}>
Cancel
</Button>
</HorizontalGroup>
</Container>
</Modal.ButtonRow>
</Modal>
);
}
@@ -104,9 +104,11 @@ function updateDuplicateLibraryPanels(modifiedPanel: PanelModel, dashboard: Dash
// Loaded plugin is not included in the persisted properties
// So is not handled by restoreModel
panel.plugin = modifiedSaveModel.plugin;
const pluginChanged = panel.plugin?.meta.id !== modifiedPanel.plugin?.meta.id;
panel.plugin = modifiedPanel.plugin;
panel.configRev++;
if (panel.type !== modifiedPanel.type) {
if (pluginChanged) {
dispatch(panelModelAndPluginReady({ panelId: panel.id, plugin: panel.plugin! }));
}
+4 -4
View File
@@ -47,6 +47,7 @@ interface Props {
logRows: LogRowModel[];
logsMeta?: LogsMetaItem[];
logsSeries?: GraphSeriesXY[];
logsQueries?: DataQuery[];
visibleRange?: AbsoluteTimeRange;
width: number;
theme: GrafanaTheme;
@@ -56,7 +57,6 @@ interface Props {
timeZone: TimeZone;
scanning?: boolean;
scanRange?: RawTimeRange;
queries: DataQuery[];
showContextToggle?: (row?: LogRowModel) => boolean;
onChangeTime: (range: AbsoluteTimeRange) => void;
onClickFilterLabel?: (key: string, value: string) => void;
@@ -243,7 +243,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
onChangeTime,
getFieldLinks,
theme,
queries,
logsQueries,
} = this.props;
const {
@@ -353,12 +353,12 @@ export class UnthemedLogs extends PureComponent<Props, State> {
</div>
<LogsNavigation
logsSortOrder={logsSortOrder}
visibleRange={visibleRange}
visibleRange={visibleRange ?? absoluteRange}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={onChangeTime}
loading={loading}
queries={queries}
queries={logsQueries ?? []}
scrollToTopLogs={this.scrollToTopLogs}
/>
</div>
@@ -64,6 +64,7 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
logRows,
logsMeta,
logsSeries,
logsQueries,
onClickFilterLabel,
onClickFilterOutLabel,
onStartScanning,
@@ -76,7 +77,6 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
width,
isLive,
exploreId,
queries,
} = this.props;
if (!logRows) {
@@ -117,6 +117,7 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
logRows={logRows}
logsMeta={logsMeta}
logsSeries={logsSeries}
logsQueries={logsQueries}
highlighterExpressions={logsHighlighterExpressions}
loading={loading}
onChangeTime={this.onChangeTime}
@@ -133,7 +134,6 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
width={width}
getRowContext={this.getLogRowContext}
getFieldLinks={this.getFieldLinks}
queries={queries}
/>
</Collapse>
</LogsCrossFadeTransition>
@@ -156,7 +156,6 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
isPaused,
range,
absoluteRange,
queries,
} = item;
const timeZone = getTimeZone(state.user);
@@ -166,6 +165,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
logRows: logsResult?.rows,
logsMeta: logsResult?.meta,
logsSeries: logsResult?.series,
logsQueries: logsResult?.queries,
visibleRange: logsResult?.visibleRange,
scanning,
timeZone,
@@ -174,7 +174,6 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
isPaused,
range,
absoluteRange,
queries,
};
}
@@ -10,7 +10,7 @@ type Props = {
timeZone: TimeZone;
queries: DataQuery[];
loading: boolean;
visibleRange?: AbsoluteTimeRange;
visibleRange: AbsoluteTimeRange;
logsSortOrder?: LogsSortOrder | null;
onChangeTime: (range: AbsoluteTimeRange) => void;
scrollToTopLogs: () => void;
@@ -28,8 +28,8 @@ function LogsNavigation({
loading,
onChangeTime,
scrollToTopLogs,
visibleRange = absoluteRange,
queries = [],
visibleRange,
queries,
}: Props) {
const [pages, setPages] = useState<LogsPage[]>([]);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
@@ -98,9 +98,10 @@ function LogsNavigation({
from: pages[currentPageIndex + 1].queryRange.from,
to: pages[currentPageIndex + 1].queryRange.to,
});
} else {
//If we are on the last page, create new range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}
//If we are on the last page, create new range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}}
disabled={loading}
>
+5 -5
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import Wrapper from './Wrapper';
import { configureStore } from '../../store/configureStore';
import { Provider } from 'react-redux';
@@ -52,7 +52,7 @@ describe('Wrapper', () => {
// At this point url should be initialised to some defaults
expect(locationService.getSearchObject()).toEqual({
orgId: 1,
orgId: '1',
left: JSON.stringify(['now-1h', 'now', 'loki', {}]),
});
expect(datasources.loki.query).not.toBeCalled();
@@ -74,7 +74,7 @@ describe('Wrapper', () => {
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: 1,
orgId: '1',
...query,
});
@@ -141,7 +141,7 @@ describe('Wrapper', () => {
await screen.findByText('elastic Editor input:');
expect(datasources.elastic.query).not.toBeCalled();
expect(locationService.getSearchObject()).toEqual({
orgId: 1,
orgId: '1',
left: JSON.stringify(['now-1h', 'now', 'elastic', {}]),
});
});
@@ -183,7 +183,7 @@ describe('Wrapper', () => {
// We did not change the url
expect(locationService.getSearchObject()).toEqual({
orgId: 1,
orgId: '1',
...query,
});
+1 -1
View File
@@ -357,7 +357,7 @@ export const runQueries = (exploreId: ExploreId, options?: { replaceUrl?: boolea
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithFrameTypeMetadata),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval })),
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })),
mergeMap(decorateWithTableResult)
)
.subscribe(
@@ -6,6 +6,7 @@ import {
PanelData,
sortLogsResult,
standardTransformers,
DataQuery,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { groupBy } from 'lodash';
@@ -129,7 +130,7 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable<Expl
};
export const decorateWithLogsResult = (
options: { absoluteRange?: AbsoluteTimeRange; refreshInterval?: string } = {}
options: { absoluteRange?: AbsoluteTimeRange; refreshInterval?: string; queries?: DataQuery[] } = {}
) => (data: ExplorePanelData): ExplorePanelData => {
if (data.logsFrames.length === 0) {
return { ...data, logsResult: null };
@@ -137,7 +138,13 @@ export const decorateWithLogsResult = (
const timeZone = data.request?.timezone ?? 'browser';
const intervalMs = data.request?.intervalMs;
const newResults = dataFrameToLogsModel(data.logsFrames, intervalMs, timeZone, options.absoluteRange);
const newResults = dataFrameToLogsModel(
data.logsFrames,
intervalMs,
timeZone,
options.absoluteRange,
options.queries
);
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);
const rows = sortedNewResults.rows;
@@ -33,8 +33,8 @@ export class AzureMonitorQueryCtrl extends QueryCtrl {
reactQueryEditors = [
AzureQueryType.AzureMonitor,
AzureQueryType.LogAnalytics,
AzureQueryType.ApplicationInsights,
AzureQueryType.InsightsAnalytics,
// AzureQueryType.ApplicationInsights,
// AzureQueryType.InsightsAnalytics,
];
// target: AzureMonitorQuery;
@@ -1,6 +1,7 @@
import React, { ChangeEvent } from 'react';
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui';
import LokiLanguageProvider from '../language_provider';
import PromQlLanguageProvider from '../../prometheus/language_provider';
import { css, cx } from '@emotion/css';
import store from 'app/core/store';
import { FixedSizeList } from 'react-window';
@@ -16,7 +17,8 @@ const EMPTY_SELECTOR = '{}';
export const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels';
export interface BrowserProps {
languageProvider: LokiLanguageProvider;
// TODO #33976: Is it possible to use a common interface here? For example: LabelsLanguageProvider
languageProvider: LokiLanguageProvider | PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme;
autoSelect?: number;
@@ -333,7 +335,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(selector);
const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
if (Object.keys(possibleLabels).length === 0) {
// Sometimes the backend does not return a valid set
console.error('No results for label combination, but should not occur.');
@@ -97,7 +97,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
this.setState({ labelsLoaded: true });
}
onChangeLogLabels = (selector: string) => {
onChangeLabelBrowser = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
@@ -174,7 +174,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
</div>
{labelBrowserVisible && (
<div className="gf-form">
<LokiLabelBrowser languageProvider={lokiLanguageProvider} onChange={this.onChangeLogLabels} />
<LokiLabelBrowser languageProvider={lokiLanguageProvider} onChange={this.onChangeLabelBrowser} />
</div>
)}
@@ -68,6 +68,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
"getBeginningCompletionItems": [Function],
"getPipeCompletionItem": [Function],
"getTermCompletionItems": [Function],
"labelFetchTs": 0,
"labelKeys": Array [],
"labelsCache": LRUCache {
Symbol(max): 10,
@@ -85,7 +86,6 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
},
Symbol(length): 0,
},
"logLabelFetchTs": 0,
"lookupsDisabled": false,
"request": [Function],
"seriesCache": LRUCache {
@@ -221,7 +221,7 @@ describe('Request URL', () => {
const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest');
const instance = new LanguageProvider(datasourceWithLabels);
instance.fetchLogLabels();
instance.fetchLabels();
const expectedUrl = '/loki/api/v1/label';
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams);
});
@@ -70,7 +70,7 @@ export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryIte
export default class LokiLanguageProvider extends LanguageProvider {
labelKeys: string[];
logLabelFetchTs: number;
labelFetchTs: number;
started = false;
datasource: LokiDatasource;
lookupsDisabled = false; // Dynamically set to true for big/slow instances
@@ -88,7 +88,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
this.datasource = datasource;
this.labelKeys = [];
this.logLabelFetchTs = 0;
this.labelFetchTs = 0;
Object.assign(this, initialValues);
}
@@ -116,7 +116,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
*/
start = () => {
if (!this.startTask) {
this.startTask = this.fetchLogLabels().then(() => {
this.startTask = this.fetchLabels().then(() => {
this.started = true;
return [];
});
@@ -415,12 +415,11 @@ export default class LokiLanguageProvider extends LanguageProvider {
/**
* Fetches all label keys
* @param absoluteRange Fetches
*/
async fetchLogLabels(): Promise<any> {
async fetchLabels(): Promise<string[]> {
const url = '/loki/api/v1/label';
const timeRange = this.datasource.getTimeRangeParams();
this.logLabelFetchTs = Date.now().valueOf();
this.labelFetchTs = Date.now().valueOf();
const res = await this.request(url, timeRange);
if (Array.isArray(res)) {
@@ -431,8 +430,8 @@ export default class LokiLanguageProvider extends LanguageProvider {
}
async refreshLogLabels(forceRefresh?: boolean) {
if ((this.labelKeys && Date.now().valueOf() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
await this.fetchLogLabels();
if ((this.labelKeys && Date.now().valueOf() - this.labelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
await this.fetchLabels();
}
}
@@ -495,17 +494,17 @@ export default class LokiLanguageProvider extends LanguageProvider {
const cacheKey = this.generateCacheKey(url, start, end, key);
const params = { start, end };
let labelValue = this.labelsCache.get(cacheKey);
if (!labelValue) {
let labelValues = this.labelsCache.get(cacheKey);
if (!labelValues) {
// Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
this.labelsCache.set(cacheKey, []);
const res = await this.request(url, params);
if (Array.isArray(res)) {
labelValue = res.slice().sort();
this.labelsCache.set(cacheKey, labelValue);
labelValues = res.slice().sort();
this.labelsCache.set(cacheKey, labelValues);
}
}
return labelValue ?? [];
return labelValues ?? [];
}
}
@@ -0,0 +1,123 @@
import React, { forwardRef, HTMLAttributes } from 'react';
import { cx, css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
// @ts-ignore
import Highlighter from 'react-highlight-words';
/**
* @public
*/
export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => void;
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
name: string;
active?: boolean;
loading?: boolean;
searchTerm?: string;
value?: string;
facets?: number;
onClick?: OnLabelClick;
}
/**
* TODO #33976: Create a common, shared component with public/app/plugins/datasource/loki/components/LokiLabel.tsx
*/
export const Label = forwardRef<HTMLElement, Props>(
({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => {
const theme = useTheme2();
const styles = getLabelStyles(theme);
const searchWords = searchTerm ? [searchTerm] : [];
const onLabelClick = (event: React.MouseEvent<HTMLElement>) => {
if (onClick && !hidden) {
onClick(name, value, event);
}
};
// Using this component for labels and label values. If value is given use value for display text.
let text = value || name;
if (facets) {
text = `${text} (${facets})`;
}
return (
<span
key={text}
ref={ref}
onClick={onLabelClick}
style={style}
title={text}
role="option"
aria-selected={!!active}
className={cx(
styles.base,
active && styles.active,
loading && styles.loading,
hidden && styles.hidden,
className,
onClick && !hidden && styles.hover
)}
{...rest}
>
<Highlighter textToHighlight={text} searchWords={searchWords} highlightClassName={styles.matchHighLight} />
</span>
);
}
);
Label.displayName = 'Label';
const getLabelStyles = (theme: GrafanaTheme2) => ({
base: css`
cursor: pointer;
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.bodySmall.lineHeight};
background-color: ${theme.colors.background.secondary};
vertical-align: baseline;
color: ${theme.colors.text};
white-space: nowrap;
text-shadow: none;
padding: ${theme.spacing(0.5)};
border-radius: ${theme.shape.borderRadius()};
margin-right: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(0.5)};
`,
loading: css`
font-weight: ${theme.typography.fontWeightMedium};
background-color: ${theme.colors.primary.shade};
color: ${theme.colors.text.primary};
animation: pulse 3s ease-out 0s infinite normal forwards;
@keyframes pulse {
0% {
color: ${theme.colors.text.primary};
}
50% {
color: ${theme.colors.text.secondary};
}
100% {
color: ${theme.colors.text.disabled};
}
}
`,
active: css`
font-weight: ${theme.typography.fontWeightMedium};
background-color: ${theme.colors.primary.main};
color: ${theme.colors.primary.contrastText};
`,
matchHighLight: css`
background: inherit;
color: ${theme.colors.primary.text};
background-color: ${theme.colors.primary.transparent};
`,
hidden: css`
opacity: 0.6;
cursor: default;
border: 1px solid transparent;
`,
hover: css`
&:hover {
opacity: 0.85;
cursor: pointer;
}
`,
});
@@ -10,6 +10,8 @@ const setup = (renderMethod: any, propOverrides?: object) => {
const datasourceMock: unknown = {
languageProvider: {
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
},
};
const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource;
@@ -2,10 +2,10 @@
import RCCascader from 'rc-cascader';
import React from 'react';
import PromQlLanguageProvider from '../language_provider';
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
import { DataSourceInstanceSettings, dateTime } from '@grafana/data';
import PromQueryField from './PromQueryField';
import { DataSourceInstanceSettings } from '@grafana/data';
import { PromOptions } from '../types';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
describe('PromQueryField', () => {
beforeAll(() => {
@@ -18,6 +18,8 @@ describe('PromQueryField', () => {
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
},
} as unknown) as DataSourceInstanceSettings<PromOptions>;
@@ -40,6 +42,8 @@ describe('PromQueryField', () => {
languageProvider: {
start: () => Promise.resolve([]),
syntax: () => {},
getLabelKeys: () => [],
metrics: [],
},
} as unknown) as DataSourceInstanceSettings<PromOptions>;
const queryField = render(
@@ -75,8 +79,6 @@ describe('PromQueryField', () => {
/>
);
checkMetricsInCascader(await screen.findByRole('button'), metrics);
const changedMetrics = ['baz', 'moo'];
queryField.rerender(
<PromQueryField
@@ -88,162 +90,9 @@ describe('PromQueryField', () => {
/>
);
// If we check the cascader right away it should be in loading state
let cascader = screen.getByRole('button');
expect(cascader.textContent).toContain('Loading');
checkMetricsInCascader(await screen.findByRole('button'), changedMetrics);
});
it('does not refreshes metrics when after rounding to minute time range does not change', async () => {
const defaultProps = {
query: { expr: '', refId: '' },
onRunQuery: () => {},
onChange: () => {},
history: [],
};
const metrics = ['foo', 'bar'];
const changedMetrics = ['foo', 'baz'];
const range = {
from: dateTime('2020-10-28T00:00:00Z'),
to: dateTime('2020-10-28T01:00:00Z'),
};
const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] });
const queryField = render(
<PromQueryField
// @ts-ignore
datasource={{ languageProvider }}
range={{
...range,
raw: range,
}}
{...defaultProps}
/>
);
checkMetricsInCascader(await screen.findByRole('button'), metrics);
const newRange = {
from: dateTime('2020-10-28T00:00:01Z'),
to: dateTime('2020-10-28T01:00:01Z'),
};
queryField.rerender(
<PromQueryField
// @ts-ignore
datasource={{ languageProvider }}
range={{
...newRange,
raw: newRange,
}}
{...defaultProps}
/>
);
let cascader = screen.getByRole('button');
// Should not show loading
expect(cascader.textContent).toContain('Metrics');
checkMetricsInCascader(await screen.findByRole('button'), metrics);
});
it('refreshes metrics when time range changes but dont show loading state', async () => {
const defaultProps = {
query: { expr: '', refId: '' },
onRunQuery: () => {},
onChange: () => {},
history: [],
};
const metrics = ['foo', 'bar'];
const changedMetrics = ['baz', 'moo'];
const range = {
from: dateTime('2020-10-28T00:00:00Z'),
to: dateTime('2020-10-28T01:00:00Z'),
};
const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] });
const queryField = render(
<PromQueryField
// @ts-ignore
datasource={{ languageProvider }}
range={{
...range,
raw: range,
}}
{...defaultProps}
/>
);
checkMetricsInCascader(await screen.findByRole('button'), metrics);
const newRange = {
from: dateTime('2020-10-28T01:00:00Z'),
to: dateTime('2020-10-28T02:00:00Z'),
};
queryField.rerender(
<PromQueryField
// @ts-ignore
datasource={{ languageProvider }}
range={{
...newRange,
raw: newRange,
}}
{...defaultProps}
/>
);
let cascader = screen.getByRole('button');
// Should not show loading
expect(cascader.textContent).toContain('Metrics');
checkMetricsInCascader(cascader, metrics);
});
});
describe('groupMetricsByPrefix()', () => {
it('returns an empty group for no metrics', () => {
expect(groupMetricsByPrefix([])).toEqual([]);
});
it('returns options grouped by prefix', () => {
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
{
value: 'foo',
children: [
{
value: 'foo_metric',
},
],
},
]);
});
it('returns options grouped by prefix with metadata', () => {
expect(groupMetricsByPrefix(['foo_metric'], { foo_metric: [{ type: 'TYPE', help: 'my help' }] })).toMatchObject([
{
value: 'foo',
children: [
{
value: 'foo_metric',
title: 'foo_metric\nTYPE\nmy help',
},
],
},
]);
});
it('returns options without prefix as toplevel option', () => {
expect(groupMetricsByPrefix(['metric'])).toMatchObject([
{
value: 'metric',
},
]);
});
it('returns recording rules grouped separately', () => {
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
{
value: RECORDING_RULES_GROUP,
children: [
{
value: ':foo_metric:',
},
],
},
]);
// If we check the label browser right away it should be in loading state
let labelBrowser = screen.getByRole('button');
expect(labelBrowser.textContent).toContain('Loading');
});
});
@@ -254,17 +103,10 @@ function makeLanguageProvider(options: { metrics: string[][] }) {
metrics: [],
metricsMetadata: {},
lookupsDisabled: false,
getLabelKeys: () => [],
start() {
this.metrics = metricsStack.shift();
return Promise.resolve([]);
},
} as any) as PromQlLanguageProvider;
}
function checkMetricsInCascader(cascader: HTMLElement, metrics: string[]) {
fireEvent.keyDown(cascader, { keyCode: 40 });
let listNodes = screen.getAllByRole('menuitem');
for (const node of listNodes) {
expect(metrics).toContain(node.innerHTML);
}
}
@@ -1,10 +1,7 @@
import { chain } from 'lodash';
import React, { ReactNode } from 'react';
import { Plugin } from 'slate';
import {
ButtonCascader,
CascaderOption,
SlatePrism,
TypeaheadInput,
TypeaheadOutput,
@@ -12,12 +9,13 @@ import {
BracesPlugin,
DOMUtil,
SuggestionsState,
Icon,
} from '@grafana/ui';
import { LanguageMap, languages as prismLanguages } from 'prismjs';
// dom also includes Element polyfills
import { PromQuery, PromOptions, PromMetricsMetadata } from '../types';
import { PromQuery, PromOptions } from '../types';
import { roundMsToMin } from '../language_utils';
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
import {
@@ -29,11 +27,11 @@ import {
TimeRange,
} from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
const HISTOGRAM_GROUP = '__histograms__';
export const RECORDING_RULES_GROUP = '__recording_rules__';
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, metrics: string[]) {
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
if (metricsLookupDisabled) {
return '(Disabled)';
}
@@ -42,56 +40,11 @@ function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, metr
return 'Loading metrics...';
}
if (metrics && metrics.length === 0) {
if (!hasMetrics) {
return '(No metrics found)';
}
return 'Metrics';
}
function addMetricsMetadata(metric: string, metadata?: PromMetricsMetadata): CascaderOption {
const option: CascaderOption = { label: metric, value: metric };
if (metadata && metadata[metric]) {
const { type = '', help } = metadata[metric][0];
option.title = [metric, type.toUpperCase(), help].join('\n');
}
return option;
}
export function groupMetricsByPrefix(metrics: string[], metadata?: PromMetricsMetadata): CascaderOption[] {
// Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/;
const ruleNames = metrics.filter((metric) => ruleRegex.test(metric));
const rulesOption = {
label: 'Recording rules',
value: RECORDING_RULES_GROUP,
children: ruleNames
.slice()
.sort()
.map((name) => ({ label: name, value: name })),
};
const options = ruleNames.length > 0 ? [rulesOption] : [];
const delimiter = '_';
const metricsOptions = chain(metrics)
.filter((metric: string) => !ruleRegex.test(metric))
.groupBy((metric: string) => metric.split(delimiter)[0])
.map(
(metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map((m) => addMetricsMetadata(m, metadata));
return {
children,
label: prefix,
value: prefix,
};
}
)
.sortBy('label')
.value();
return [...options, ...metricsOptions];
return 'Metrics browser';
}
export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
@@ -127,7 +80,7 @@ interface PromQueryFieldProps extends ExploreQueryFieldProps<PrometheusDatasourc
}
interface PromQueryFieldState {
metricsOptions: any[];
labelBrowserVisible: boolean;
syntaxLoaded: boolean;
hint: QueryHint | null;
}
@@ -151,7 +104,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
];
this.state = {
metricsOptions: [],
labelBrowserVisible: false,
syntaxLoaded: false,
hint: null,
};
@@ -181,7 +134,6 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every
// query run if using relative range.
this.setState({
metricsOptions: [],
syntaxLoaded: false,
});
}
@@ -247,26 +199,12 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
return false;
}
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
const selectedOption = selectedOptions[0];
if (!selectedOption.children || selectedOption.children.length === 0) {
query = selectedOption.value;
} else {
// Ignore click on group
return;
}
} else {
const prefix = selectedOptions[0].value;
const metric = selectedOptions[1].value;
if (prefix === HISTOGRAM_GROUP) {
query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
} else {
query = metric;
}
}
this.onChangeQuery(query, true);
/**
* TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;)
*/
onChangeLabelBrowser = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
onChangeQuery = (value: string, override?: boolean) => {
@@ -282,6 +220,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
}
};
onClickChooserButton = () => {
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
};
onClickHintFix = () => {
const { datasource, query, onChange, onRunQuery } = this.props;
const { hint } = this.state;
@@ -294,24 +236,13 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const {
datasource: { languageProvider },
} = this.props;
const { histogramMetrics, metrics, metricsMetadata } = languageProvider;
const { metrics } = languageProvider;
if (!metrics) {
return;
}
// Build metrics tree
const metricsByPrefix = groupMetricsByPrefix(metrics, metricsMetadata);
const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm }));
const metricsOptions =
histogramMetrics.length > 0
? [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions, isLeaf: false },
...metricsByPrefix,
]
: metricsByPrefix;
this.setState({ metricsOptions, syntaxLoaded: true });
this.setState({ syntaxLoaded: true });
};
onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
@@ -341,19 +272,24 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
query,
ExtraFieldElement,
} = this.props;
const { metricsOptions, syntaxLoaded, hint } = this.state;
const { labelBrowserVisible, syntaxLoaded, hint } = this.state;
const cleanText = languageProvider ? languageProvider.cleanText : undefined;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, metricsOptions);
const buttonDisabled = !(syntaxLoaded && metricsOptions && metricsOptions.length > 0);
const hasMetrics = languageProvider.metrics.length > 0;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics);
return (
<>
<div className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1">
<div className="gf-form flex-shrink-0 min-width-5">
<ButtonCascader options={metricsOptions} disabled={buttonDisabled} onChange={this.onChangeMetrics}>
{chooserText}
</ButtonCascader>
</div>
<button
className="gf-form-label query-keyword pointer"
onClick={this.onClickChooserButton}
disabled={buttonDisabled}
>
{chooserText}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
<QueryField
additionalPlugins={this.plugins}
@@ -370,6 +306,12 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
/>
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<PrometheusMetricsBrowser languageProvider={languageProvider} onChange={this.onChangeLabelBrowser} />
</div>
)}
{ExtraFieldElement}
{hint ? (
<div className="query-row-break">
@@ -0,0 +1,265 @@
import React from 'react';
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getTheme } from '@grafana/ui';
import {
buildSelector,
facetLabels,
SelectableLabel,
UnthemedPrometheusMetricsBrowser,
BrowserProps,
} from './PrometheusMetricsBrowser';
import PromQlLanguageProvider from '../language_provider';
describe('buildSelector()', () => {
it('returns an empty selector for no labels', () => {
expect(buildSelector([])).toEqual('{}');
});
it('returns an empty selector for selected labels with no values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns an empty selector for one selected label with no selected values', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }];
expect(buildSelector(labels)).toEqual('{}');
});
it('returns a simple selector from a selected label with a selected value', () => {
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }];
expect(buildSelector(labels)).toEqual('{foo="bar"}');
});
it('metric selector without labels', () => {
const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }];
expect(buildSelector(labels)).toEqual('foo{}');
});
it('selector with multiple metrics', () => {
const labels: SelectableLabel[] = [
{
name: '__name__',
selected: true,
values: [
{ name: 'foo', selected: true },
{ name: 'bar', selected: true },
],
},
];
expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}');
});
it('metric selector with labels', () => {
const labels: SelectableLabel[] = [
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] },
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] },
];
expect(buildSelector(labels)).toEqual('foo{bar="baz"}');
});
});
describe('facetLabels()', () => {
const possibleLabels = {
cluster: ['dev'],
namespace: ['alertmanager'],
};
const labels: SelectableLabel[] = [
{ name: 'foo', selected: true, values: [{ name: 'bar' }] },
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] },
{ name: 'namespace', values: [{ name: 'alertmanager' }] },
];
it('returns no labels given an empty label set', () => {
expect(facetLabels([], {})).toEqual([]);
});
it('marks all labels as hidden when no labels are possible', () => {
const result = facetLabels(labels, {});
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
});
it('keeps values as facetted when they are possible', () => {
const result = facetLabels(labels, possibleLabels);
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[0].values).toBeUndefined();
expect(result[1].hidden).toBeFalsy();
expect(result[1].values!.length).toBe(1);
expect(result[1].values![0].name).toBe('dev');
});
it('does not facet out label values that are currently being facetted', () => {
const result = facetLabels(labels, possibleLabels, 'cluster');
expect(result.length).toEqual(labels.length);
expect(result[0].hidden).toBeTruthy();
expect(result[1].hidden).toBeFalsy();
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
expect(result[1].values!.length).toBe(3);
expect(result[2].values!.length).toBe(1);
});
});
describe('PrometheusMetricsBrowser', () => {
const setupProps = (): BrowserProps => {
const mockLanguageProvider = {
start: () => Promise.resolve(),
getLabelValues: (name: string) => {
switch (name) {
case 'label1':
return ['value1-1', 'value1-2'];
case 'label2':
return ['value2-1', 'value2-2'];
case 'label3':
return ['value3-1', 'value3-2'];
}
return [];
},
fetchSeriesLabels: (selector: string) => {
switch (selector) {
case '{label1="value1-1"}':
return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] };
case '{label1=~"value1-1|value1-2"}':
return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] };
}
// Allow full set by default
return {
label1: ['value1-1', 'value1-2'],
label2: ['value2-1', 'value2-2'],
};
},
getLabelKeys: () => ['label1', 'label2', 'label3'],
};
const defaults: BrowserProps = {
theme: getTheme(),
onChange: () => {},
autoSelect: 0,
languageProvider: (mockLanguageProvider as unknown) as PromQlLanguageProvider,
};
return defaults;
};
// Clear label selection manually because it's saved in localStorage
afterEach(() => {
const clearBtn = screen.getByLabelText('Selector clear button');
userEvent.click(clearBtn);
});
it('renders and loader shows when empty, and then first set of labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Loading appears and dissappears
screen.getByText(/Loading labels/);
await waitFor(() => {
expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument();
});
// Initial set of labels is available and not selected
expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('allows label and value selection/deselection', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument();
userEvent.click(label2);
expect(screen.queryByRole('option', { name: /label2/, selected: true })).toBeInTheDocument();
// List of values for label2 appears
expect(await screen.findAllByRole('list')).toHaveLength(1);
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
userEvent.click(label1);
expect(screen.queryByRole('option', { name: /label1/, selected: true })).toBeInTheDocument();
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-2 of label2
const value = await screen.findByRole('option', { name: 'value2-2', selected: false });
userEvent.click(value);
await screen.findByRole('option', { name: 'value2-2', selected: true });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}');
// Selecting value2-1 of label2, both values now selected
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
userEvent.click(value2);
// await screen.findByRole('option', {name: 'value2-1', selected: true});
await screen.findByText('{label2=~"value2-1|value2-2"}');
// Deselecting value2-2, one value should remain
const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true });
userEvent.click(selectedValue);
await screen.findByRole('option', { name: 'value2-1', selected: true });
await screen.findByRole('option', { name: 'value2-2', selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
// Selecting value from label1 for combined selector
const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false });
userEvent.click(value1);
await screen.findByRole('option', { name: 'value1-2', selected: true });
await screen.findByText('{label1="value1-2",label2="value2-1"}');
// Deselect label1 should remove label and value
const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0];
userEvent.click(selectedLabel);
await screen.findByRole('option', { name: /label1/, selected: false });
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1);
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
// Clear selector
const clearBtn = screen.getByLabelText('Selector clear button');
userEvent.click(clearBtn);
await screen.findByRole('option', { name: /label2/, selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('filters values by input text', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
// Typing '1' to filter for values
userEvent.type(screen.getByLabelText('Filter expression for label values'), '1');
expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1');
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument();
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3);
});
it('facets labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3');
// Click value1-1 which triggers facetting for value3-x, and still show all value1-x
const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false });
userEvent.click(value1);
await waitForElementToBeRemoved(screen.queryByRole('option', { name: 'value2-2' }));
expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}');
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)');
// Click value1-2 for which facetting will allow more values for value3-x
const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false });
userEvent.click(value12);
await screen.findByRole('option', { name: 'value1-2', selected: true });
userEvent.click(screen.getByRole('option', { name: /label3/ }));
await screen.findByLabelText('Values for label3');
expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}');
expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)');
});
});
@@ -0,0 +1,633 @@
import React, { ChangeEvent } from 'react';
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui';
import PromQlLanguageProvider from '../language_provider';
import { css, cx } from '@emotion/css';
import store from 'app/core/store';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme } from '@grafana/data';
import { Label as PromLabel } from './Label';
// Hard limit on labels to render
const MAX_LABEL_COUNT = 10000;
const MAX_VALUE_COUNT = 10000;
const EMPTY_SELECTOR = '{}';
const METRIC_LABEL = '__name__';
export const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
export interface BrowserProps {
languageProvider: PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme;
autoSelect?: number;
hide?: () => void;
}
interface BrowserState {
labels: SelectableLabel[];
labelSearchTerm: string;
metricSearchTerm: string;
status: string;
error: string;
validationStatus: string;
valueSearchTerm: string;
}
interface FacettableValue {
name: string;
selected?: boolean;
}
export interface SelectableLabel {
name: string;
selected?: boolean;
loading?: boolean;
values?: FacettableValue[];
hidden?: boolean;
facets?: number;
}
export function buildSelector(labels: SelectableLabel[]): string {
let singleMetric = '';
const selectedLabels = [];
for (const label of labels) {
if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
if (selectedValues.length > 1) {
selectedLabels.push(`${label.name}=~"${selectedValues.join('|')}"`);
} else if (selectedValues.length === 1) {
if (label.name === METRIC_LABEL) {
singleMetric = selectedValues[0];
} else {
selectedLabels.push(`${label.name}="${selectedValues[0]}"`);
}
}
}
}
return [singleMetric, '{', selectedLabels.join(','), '}'].join('');
}
export function facetLabels(
labels: SelectableLabel[],
possibleLabels: Record<string, string[]>,
lastFacetted?: string
): SelectableLabel[] {
return labels.map((label) => {
const possibleValues = possibleLabels[label.name];
if (possibleValues) {
let existingValues: FacettableValue[];
if (label.name === lastFacetted && label.values) {
// Facetting this label, show all values
existingValues = label.values;
} else {
// Keep selection in other facets
const selectedValues: Set<string> = new Set(
label.values?.filter((value) => value.selected).map((value) => value.name) || []
);
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
}
return {
...label,
loading: false,
values: existingValues,
hidden: !possibleValues,
facets: existingValues.length,
};
}
// Label is facetted out, hide all values
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
});
}
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
wrapper: css`
background-color: ${theme.colors.bg2};
padding: ${theme.spacing.md};
width: 100%;
`,
list: css`
margin-top: ${theme.spacing.sm};
display: flex;
flex-wrap: wrap;
max-height: 200px;
overflow: auto;
`,
section: css`
& + & {
margin: ${theme.spacing.md} 0;
}
position: relative;
`,
selector: css`
font-family: ${theme.typography.fontFamily.monospace};
margin-bottom: ${theme.spacing.sm};
`,
status: css`
padding: ${theme.spacing.xs};
color: ${theme.colors.textSemiWeak};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* using absolute positioning because flex interferes with ellipsis */
position: absolute;
width: 50%;
right: 0;
text-align: right;
transition: opacity 100ms linear;
opacity: 0;
`,
statusShowing: css`
opacity: 1;
`,
error: css`
color: ${theme.palette.brandDanger};
`,
valueList: css`
margin-right: ${theme.spacing.sm};
`,
valueListWrapper: css`
border-left: 1px solid ${theme.colors.border2};
margin: ${theme.spacing.sm} 0;
padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm};
`,
valueListArea: css`
display: flex;
flex-wrap: wrap;
margin-top: ${theme.spacing.sm};
`,
valueTitle: css`
margin-left: -${theme.spacing.xs};
margin-bottom: ${theme.spacing.sm};
`,
validationStatus: css`
padding: ${theme.spacing.xs};
margin-bottom: ${theme.spacing.sm};
color: ${theme.colors.textStrong};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
}));
/**
* TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible
* to create a single, generic component.
*/
export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> {
state = {
labels: [] as SelectableLabel[],
labelSearchTerm: '',
metricSearchTerm: '',
status: 'Ready',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ labelSearchTerm: event.target.value });
};
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ metricSearchTerm: event.target.value });
};
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ valueSearchTerm: event.target.value });
};
onClickRunQuery = () => {
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunRateQuery = () => {
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__interval])`;
this.props.onChange(query);
};
onClickClear = () => {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => ({
...label,
values: undefined,
selected: false,
loading: false,
hidden: false,
facets: undefined,
}));
return {
labels,
labelSearchTerm: '',
metricSearchTerm: '',
status: '',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
});
store.delete(LAST_USED_LABELS_KEY);
// Get metrics
this.fetchValues(METRIC_LABEL);
};
onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
// Toggle selected state
const selected = !label.selected;
let nextValue: Partial<SelectableLabel> = { selected };
if (label.values && !selected) {
// Deselect all values if label was deselected
const values = label.values.map((value) => ({ ...value, selected: false }));
nextValue = { ...nextValue, facets: 0, values };
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
};
onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
};
onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
// Finding special metric label
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ metricSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({
...v,
selected: v.name === value || v.selected ? !v.selected : v.selected,
}));
// Toggle selected state of special metrics label
const selected = values.some((v) => v.selected);
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
};
onClickValidate = () => {
const selector = buildSelector(this.state.labels);
this.validateSelector(selector);
};
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => {
if (label.name === name) {
return { ...label, ...updatedFields };
}
return label;
});
// New status overrides errors
const error = status ? '' : state.error;
return { labels, status, error, validationStatus: '' };
}, cb);
}
componentDidMount() {
const { languageProvider } = this.props;
if (languageProvider) {
const selectedLabels: string[] = store.getObject(LAST_USED_LABELS_KEY, []);
languageProvider.start().then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys();
// TODO too-many-metrics
if (rawLabels.length > MAX_LABEL_COUNT) {
const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`;
rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT);
this.setState({ error });
}
// Get metrics
this.fetchValues(METRIC_LABEL);
// Auto-select previously selected labels
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
name: label,
selected: selectedLabels.includes(label),
loading: false,
}));
// Pre-fetch values for selected labels
this.setState({ labels }, () => {
this.state.labels.forEach((label) => {
if (label.selected) {
this.fetchValues(label.name);
}
});
});
});
}
}
doFacettingForLabel(name: string) {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
store.setObject(LAST_USED_LABELS_KEY, selectedLabels);
if (label.selected) {
// Refetch values for newly selected label...
if (!label.values) {
this.fetchValues(name);
}
} else {
// Only need to facet when deselecting labels
this.doFacetting();
}
}
doFacetting = (lastFacetted?: string) => {
const selector = buildSelector(this.state.labels);
if (selector === EMPTY_SELECTOR) {
// Clear up facetting
const labels: SelectableLabel[] = this.state.labels.map((label) => {
return { ...label, facets: 0, values: undefined, hidden: false };
});
this.setState({ labels }, () => {
// Get fresh set of values
this.state.labels.forEach(
(label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name)
);
});
} else {
// Do facetting
this.fetchSeries(selector, lastFacetted);
}
};
async fetchValues(name: string) {
const { languageProvider } = this.props;
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
try {
let rawValues = await languageProvider.getLabelValues(name);
if (rawValues.length > MAX_VALUE_COUNT) {
const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`;
rawValues = rawValues.slice(0, MAX_VALUE_COUNT);
this.setState({ error });
}
const values: FacettableValue[] = rawValues.map((value) => ({ name: value }));
this.updateLabelState(name, { values, loading: false }, '');
} catch (error) {
console.error(error);
}
}
async fetchSeries(selector: string, lastFacetted?: string) {
const { languageProvider } = this.props;
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
if (Object.keys(possibleLabels).length === 0) {
// Sometimes the backend does not return a valid set
console.error('No results for label combination, but should not occur.');
this.setState({ error: `Facetting failed for ${selector}` });
return;
}
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
this.setState({ labels, error: '' });
if (lastFacetted) {
this.updateLabelState(lastFacetted, { loading: false });
}
} catch (error) {
console.error(error);
}
}
async validateSelector(selector: string) {
const { languageProvider } = this.props;
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
const streams = await languageProvider.fetchSeries(selector);
this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` });
}
render() {
const { theme } = this.props;
const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
const styles = getStyles(theme);
if (labels.length === 0) {
return (
<div className={styles.wrapper}>
<LoadingPlaceholder text="Loading labels..." />
</div>
);
}
// Filter metrics
let metrics = labels.find((label) => label.name === METRIC_LABEL);
if (metrics && metricSearchTerm) {
// TODO extract from render() and debounce
metrics = {
...metrics,
values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
};
}
// Filter labels
let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
if (labelSearchTerm) {
// TODO extract from render() and debounce
nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
}
// Filter non-metric label values
let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
if (valueSearchTerm) {
// TODO extract from render() and debounce
selectedLabels = selectedLabels.map((label) => ({
...label,
values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
}));
}
const selector = buildSelector(this.state.labels);
const empty = selector === EMPTY_SELECTOR;
return (
<div className={styles.wrapper}>
<HorizontalGroup align="flex-start" spacing="lg">
<div>
<div className={styles.section}>
<Label description="Which metric do you want to use?">1. Select metric to search in</Label>
<div>
<Input
onChange={this.onChangeMetricSearch}
aria-label="Filter expression for metric"
value={metricSearchTerm}
/>
</div>
<div role="list" className={styles.valueListWrapper}>
<FixedSizeList
height={550}
itemCount={metrics?.values?.length || 0}
itemSize={25}
itemKey={(i) => (metrics!.values as FacettableValue[])[i].name}
width={300}
className={styles.valueList}
>
{({ index, style }) => {
const value = metrics?.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={metrics!.name}
value={value?.name}
active={value?.selected}
onClick={this.onClickMetric}
searchTerm={metricSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
</div>
</div>
<div>
<div className={styles.section}>
<Label description="Which labels would you like to consider for your search?">
2. Select labels to search in
</Label>
<div>
<Input
onChange={this.onChangeLabelSearch}
aria-label="Filter expression for label"
value={labelSearchTerm}
/>
</div>
<div className={styles.list}>
{nonMetricLabels.map((label) => (
<PromLabel
key={label.name}
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
facets={label.facets}
onClick={this.onClickLabel}
searchTerm={labelSearchTerm}
/>
))}
</div>
</div>
<div className={styles.section}>
<Label description="Choose the label values that you would like to use for the query. Use the search field to find values across selected labels.">
3. Find values for the selected labels
</Label>
<div>
<Input
onChange={this.onChangeValueSearch}
aria-label="Filter expression for label values"
value={valueSearchTerm}
/>
</div>
<div className={styles.valueListArea}>
{selectedLabels.map((label) => (
<div
role="list"
key={label.name}
aria-label={`Values for ${label.name}`}
className={styles.valueListWrapper}
>
<div className={styles.valueTitle}>
<PromLabel
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
//If no facets, we want to show number of all label values
facets={label.facets || label.values?.length}
onClick={this.onClickLabel}
/>
</div>
<FixedSizeList
height={200}
itemCount={label.values?.length || 0}
itemSize={25}
itemKey={(i) => (label.values as FacettableValue[])[i].name}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={label.name}
value={value?.name}
active={value?.selected}
onClick={this.onClickValue}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
))}
</div>
</div>
</div>
</HorizontalGroup>
<div className={styles.section}>
<Label>4. Resulting selector</Label>
<div aria-label="selector" className={styles.selector}>
{selector}
</div>
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
<HorizontalGroup>
<Button aria-label="Use selector for query button" disabled={empty} onClick={this.onClickRunQuery}>
Run query
</Button>
<Button
aria-label="Use selector as metrics button"
variant="secondary"
disabled={empty}
onClick={this.onClickRunRateQuery}
>
Run rate query
</Button>
<Button
aria-label="Validate submit button"
variant="secondary"
disabled={empty}
onClick={this.onClickValidate}
>
Validate selector
</Button>
<Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}>
Clear
</Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
</div>
</HorizontalGroup>
</div>
</div>
);
}
}
export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser);
@@ -7,6 +7,8 @@ exports[`PromExploreQueryEditor should render component 1`] = `
datasource={
Object {
"languageProvider": Object {
"getLabelKeys": [Function],
"metrics": Array [],
"syntax": [Function],
},
}
@@ -63,6 +65,8 @@ exports[`PromExploreQueryEditor should render component 1`] = `
datasource={
Object {
"languageProvider": Object {
"getLabelKeys": [Function],
"metrics": Array [],
"syntax": [Function],
},
}
@@ -804,11 +804,11 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
return Math.ceil(date.valueOf() / 1000);
}
getTimeRange(): { start: number; end: number } {
getTimeRangeParams(): { start: string; end: string } {
const range = this.timeSrv.timeRange();
return {
start: this.getPrometheusTime(range.from, false),
end: this.getPrometheusTime(range.to, true),
start: this.getPrometheusTime(range.from, false).toString(),
end: this.getPrometheusTime(range.to, true).toString(),
};
}
@@ -10,7 +10,7 @@ import { SearchFunctionType } from '@grafana/ui';
describe('Language completion provider', () => {
const datasource: PrometheusDatasource = ({
metadataRequest: () => ({ data: { data: [] as any[] } }),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
describe('cleanText', () => {
@@ -249,7 +249,7 @@ describe('Language completion provider', () => {
it('returns label suggestions on label context and metric', async () => {
const datasources: PrometheusDatasource = ({
metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasources);
const value = Plain.deserialize('metric{}');
@@ -282,7 +282,7 @@ describe('Language completion provider', () => {
],
},
}),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}');
@@ -519,7 +519,7 @@ describe('Language completion provider', () => {
it('does not re-fetch default labels', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: [] as any[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@@ -545,7 +545,7 @@ describe('Language completion provider', () => {
it('does not issue any metadata requests when lookup is disabled', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
lookupsDisabled: true,
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@@ -568,7 +568,7 @@ describe('Language completion provider', () => {
it('issues metadata requests when lookup is not disabled', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
lookupsDisabled: false,
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@@ -69,6 +69,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
metricsMetadata?: PromMetricsMetadata;
startTask: Promise<any>;
datasource: PrometheusDatasource;
labelKeys: string[];
labelFetchTs: number;
/**
* Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does
@@ -115,20 +117,19 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return [];
}
const tRange = this.datasource.getTimeRange();
const params = {
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
const url = `/api/v1/label/__name__/values`;
this.metrics = await this.request(url, [], params);
// TODO #33976: make those requests parallel
await this.fetchLabels();
this.metrics = await this.fetchLabelValues('__name__');
this.metricsMetadata = fixSummariesMetadata(await this.request('/api/v1/metadata', {}));
this.processHistogramMetrics(this.metrics);
return [];
};
getLabelKeys(): string[] {
return this.labelKeys;
}
processHistogramMetrics = (data: string[]) => {
const { values } = processHistogramLabels(data);
@@ -308,12 +309,13 @@ export default class PromQlLanguageProvider extends LanguageProvider {
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelValues = await this.getLabelValues(selector);
if (labelValues) {
const limitInfo = addLimitInfo(labelValues[0]);
const series = await this.getSeries(selector);
const labelKeys = Object.keys(series);
if (labelKeys.length > 0) {
const limitInfo = addLimitInfo(labelKeys);
suggestions.push({
label: `Labels${limitInfo}`,
items: Object.keys(labelValues).map(wrapLabel),
items: labelKeys.map(wrapLabel),
searchFunctionType: SearchFunctionType.Fuzzy,
});
}
@@ -360,13 +362,13 @@ export default class PromQlLanguageProvider extends LanguageProvider {
const containsMetric = selector.includes('__name__=');
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
let labelValues;
let series: Record<string, string[]> = {};
// Query labels for selector
if (selector) {
labelValues = await this.getLabelValues(selector, !containsMetric);
series = await this.getSeries(selector, !containsMetric);
}
if (!labelValues) {
if (Object.keys(series).length === 0) {
console.warn(`Server did not return any values for selector = ${selector}`);
return { suggestions };
}
@@ -375,18 +377,18 @@ export default class PromQlLanguageProvider extends LanguageProvider {
if ((text && isValueStart) || wrapperClasses.includes('attr-value')) {
// Label values
if (labelKey && labelValues[labelKey]) {
if (labelKey && series[labelKey]) {
context = 'context-label-values';
const limitInfo = addLimitInfo(labelValues[labelKey]);
const limitInfo = addLimitInfo(series[labelKey]);
suggestions.push({
label: `Label values for "${labelKey}"${limitInfo}`,
items: labelValues[labelKey].map(wrapLabel),
items: series[labelKey].map(wrapLabel),
searchFunctionType: SearchFunctionType.Fuzzy,
});
}
} else {
// Label keys
const labelKeys = labelValues ? Object.keys(labelValues) : containsMetric ? null : DEFAULT_KEYS;
const labelKeys = series ? Object.keys(series) : containsMetric ? null : DEFAULT_KEYS;
if (labelKeys) {
const possibleKeys = difference(labelKeys, existingKeys);
@@ -407,34 +409,49 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return { context, suggestions };
};
async getLabelValues(selector: string, withName?: boolean) {
async getSeries(selector: string, withName?: boolean): Promise<Record<string, string[]>> {
if (this.datasource.lookupsDisabled) {
return undefined;
return {};
}
try {
if (selector === EMPTY_SELECTOR) {
return await this.fetchDefaultLabels();
return await this.fetchDefaultSeries();
} else {
return await this.fetchSeriesLabels(selector, withName);
}
} catch (error) {
// TODO: better error handling
console.error(error);
return undefined;
return {};
}
}
fetchLabelValues = async (key: string): Promise<Record<string, string[]>> => {
const tRange = this.datasource.getTimeRange();
const params = {
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
fetchLabelValues = async (key: string): Promise<string[]> => {
const params = this.datasource.getTimeRangeParams();
const url = `/api/v1/label/${key}/values`;
const data = await this.request(url, [], params);
return { [key]: data };
return await this.request(url, [], params);
};
async getLabelValues(key: string): Promise<string[]> {
return await this.fetchLabelValues(key);
}
/**
* Fetches all label keys
*/
async fetchLabels(): Promise<string[]> {
const url = '/api/v1/labels';
const params = this.datasource.getTimeRangeParams();
this.labelFetchTs = Date.now().valueOf();
const res = await this.request(url, [], params);
if (Array.isArray(res)) {
this.labelKeys = res.slice().sort();
}
return [];
}
/**
* Fetch labels for a series. This is cached by it's args but also by the global timeRange currently selected as
* they can change over requested time.
@@ -442,11 +459,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
* @param withName
*/
fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
const tRange = this.datasource.getTimeRange();
const range = this.datasource.getTimeRangeParams();
const urlParams = {
...range,
'match[]': name,
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
const url = `/api/v1/series`;
// Cache key is a bit different here. We add the `withName` param and also round up to a minute the intervals.
@@ -455,8 +471,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
// when user does not the newest values for a minute if already cached.
const cacheParams = new URLSearchParams({
'match[]': name,
start: roundSecToMin(tRange['start']).toString(),
end: roundSecToMin(tRange['end']).toString(),
start: roundSecToMin(parseInt(range.start, 10)).toString(),
end: roundSecToMin(parseInt(range.end, 10)).toString(),
withName: withName ? 'true' : 'false',
});
@@ -471,13 +487,24 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return value;
};
/**
* Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
* @param match
*/
fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
const url = '/api/v1/series';
const range = this.datasource.getTimeRangeParams();
const params = { ...range, match };
return await this.request(url, {}, params);
};
/**
* Fetch this only one as we assume this won't change over time. This is cached differently from fetchSeriesLabels
* because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in
* fetchSeriesLabels.
*/
fetchDefaultLabels = once(async () => {
fetchDefaultSeries = once(async () => {
const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key)));
return values.reduce((acc, value) => ({ ...acc, ...value }), {});
return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {});
});
}
@@ -1,4 +1,4 @@
import { DashboardCursorSync, Field, PanelProps } from '@grafana/data';
import { anySeriesWithTimeField, DashboardCursorSync, Field, PanelProps } from '@grafana/data';
import { TooltipDisplayMode, usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import React from 'react';
@@ -19,12 +19,12 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onChangeTimeRange,
replaceVariables,
}) => {
const { sync } = usePanelContext();
const getFieldLinks = (field: Field, rowIndex: number) => {
return getFieldLinksForExplore({ field, rowIndex, range: timeRange });
};
const { sync } = usePanelContext();
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
@@ -33,6 +33,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
);
}
if (!anySeriesWithTimeField(data.series)) {
return (
<div className="panel-empty">
<p>Missing time field in the data</p>
</div>
);
}
return (
<TimeSeries
frames={data.series}
+39 -85
View File
@@ -1,7 +1,10 @@
load('scripts/vault.star', 'from_secret', 'github_token', 'pull_secret')
grabpl_version = '2.0.0'
build_image = 'grafana/build-container:1.4.1'
publish_image = 'grafana/grafana-ci-deploy:1.3.1'
grafana_docker_image = 'grafana/drone-grafana-docker:0.3.2'
deploy_docker_image = 'us.gcr.io/kubernetes-dev/drone/plugins/deploy-image'
alpine_image = 'alpine:3.13'
windows_image = 'mcr.microsoft.com/windows:1809'
dockerize_version = '0.6.1'
@@ -38,6 +41,7 @@ def pipeline(
}
if edition in ('enterprise', 'enterprise2'):
pipeline['image_pull_secrets'] = [pull_secret]
# We have a custom clone step for enterprise
pipeline['clone'] = {
'disable': True,
@@ -67,9 +71,7 @@ def slack_step(channel):
'name': 'slack',
'image': 'plugins/slack',
'settings': {
'webhook': {
'from_secret': 'slack_webhook',
},
'webhook': from_secret('slack_webhook'),
'channel': channel,
'template': 'Build {{build.number}} failed for commit: <https://github.com/{{repo.owner}}/{{repo.name}}/commit/{{build.commit}}|{{ truncate build.commit 8 }}>: {{build.link}}\nAuthor: {{build.author}}',
},
@@ -137,9 +139,7 @@ def init_steps(edition, platform, ver_mode, is_downstream=False, install_deps=Tr
'name': 'clone',
'image': build_image,
'environment': {
'GITHUB_TOKEN': {
'from_secret': 'github_token',
},
'GITHUB_TOKEN': from_secret(github_token),
},
'commands': download_grabpl_cmds + [
'git clone "https://$${GITHUB_TOKEN}@github.com/grafana/grafana-enterprise.git"',
@@ -192,9 +192,7 @@ def enterprise_downstream_step(edition):
'image': 'grafana/drone-downstream',
'settings': {
'server': 'https://drone.grafana.net',
'token': {
'from_secret': 'drone_token',
},
'token': from_secret('drone_token'),
'repositories': [
'grafana/grafana-enterprise@main',
],
@@ -300,9 +298,7 @@ def publish_storybook_step(edition, ver_mode):
'end-to-end-tests',
],
'environment': {
'GCP_KEY': {
'from_secret': 'gcp_key',
},
'GCP_KEY': from_secret('gcp_key'),
},
'commands': commands,
}
@@ -315,9 +311,7 @@ def upload_cdn(edition):
'package' + enterprise2_sfx(edition),
],
'environment': {
'GCP_GRAFANA_UPLOAD_KEY': {
'from_secret': 'gcp_key',
},
'GCP_GRAFANA_UPLOAD_KEY': from_secret('gcp_key'),
},
'commands': [
'./bin/grabpl upload-cdn --edition {} --bucket "grafana-static-assets"'.format(edition),
@@ -332,9 +326,7 @@ def build_backend_step(edition, ver_mode, variants=None, is_downstream=False):
# TODO: Convert number of jobs to percentage
if ver_mode == 'release':
env = {
'GITHUB_TOKEN': {
'from_secret': 'github_token',
},
'GITHUB_TOKEN': from_secret(github_token),
}
cmds = [
'./bin/grabpl build-backend --jobs 8 --edition {} --github-token $${{GITHUB_TOKEN}} --no-pull-enterprise ${{DRONE_TAG}}'.format(
@@ -343,9 +335,7 @@ def build_backend_step(edition, ver_mode, variants=None, is_downstream=False):
]
elif ver_mode == 'test-release':
env = {
'GITHUB_TOKEN': {
'from_secret': 'github_token',
},
'GITHUB_TOKEN': from_secret(github_token),
}
cmds = [
'./bin/grabpl build-backend --jobs 8 --edition {} --github-token $${{GITHUB_TOKEN}} --no-pull-enterprise {}'.format(
@@ -424,9 +414,7 @@ def build_frontend_docs_step(edition):
def build_plugins_step(edition, sign=False):
if sign:
env = {
'GRAFANA_API_KEY': {
'from_secret': 'grafana_api_key',
},
'GRAFANA_API_KEY': from_secret('grafana_api_key'),
}
sign_args = ' --sign --signing-admin'
else:
@@ -489,9 +477,7 @@ def frontend_metrics_step(edition):
'initialize',
],
'environment': {
'GRAFANA_MISC_STATS_API_KEY': {
'from_secret': 'grafana_misc_stats_api_key',
},
'GRAFANA_MISC_STATS_API_KEY': from_secret('grafana_misc_stats_api_key'),
},
'failure': 'ignore',
'commands': [
@@ -572,21 +558,11 @@ def package_step(edition, ver_mode, variants=None, is_downstream=False):
if ver_mode in ('main', 'release', 'test-release', 'release-branch'):
sign_args = ' --sign'
env = {
'GRAFANA_API_KEY': {
'from_secret': 'grafana_api_key',
},
'GITHUB_TOKEN': {
'from_secret': 'github_token',
},
'GPG_PRIV_KEY': {
'from_secret': 'gpg_priv_key',
},
'GPG_PUB_KEY': {
'from_secret': 'gpg_pub_key',
},
'GPG_KEY_PASSWORD': {
'from_secret': 'gpg_key_password',
},
'GRAFANA_API_KEY': from_secret('grafana_api_key'),
'GITHUB_TOKEN': from_secret(github_token),
'GPG_PRIV_KEY': from_secret('gpg_priv_key'),
'GPG_PUB_KEY': from_secret('gpg_pub_key'),
'GPG_KEY_PASSWORD': from_secret('gpg_key_password'),
}
test_args = ''
else:
@@ -724,12 +700,8 @@ def build_docker_images_step(edition, ver_mode, archs=None, ubuntu=False, publis
}
if publish:
settings['username'] = {
'from_secret': 'docker_user',
}
settings['password'] = {
'from_secret': 'docker_password',
}
settings['username'] = from_secret('docker_user')
settings['password'] = from_secret('docker_password')
if archs:
settings['archs'] = ','.join(archs)
return {
@@ -832,33 +804,31 @@ def release_canary_npm_packages_step(edition):
'end-to-end-tests',
],
'environment': {
'GITHUB_PACKAGE_TOKEN': {
'from_secret': 'github_package_token',
},
'GITHUB_PACKAGE_TOKEN': from_secret('github_package_token'),
},
'commands': [
'./scripts/circle-release-canary-packages.sh',
],
}
def deploy_to_kubernetes_step(edition, is_downstream=False):
def push_to_deployment_tools_step(edition, is_downstream=False):
if edition != 'enterprise' or not is_downstream:
return None
return {
'name': 'deploy-to-kubernetes',
'image': alpine_image,
'name': 'push-to-deployment_tools',
'image': deploy_docker_image,
'depends_on': [
'build-docker-images',
# This step should have all the dependencies required for packaging, and should generate
# dist/grafana.version
'gen-version',
],
'environment': {
'CIRCLE_TOKEN': {
'from_secret': 'deployment_tools_circle_token',
},
'settings': {
'github_token': from_secret(github_token),
'images_file': './deployment_tools_config.json',
'docker_tag_file': './dist/grafana.version'
},
'commands': [
'./bin/grabpl deploy-to-k8s',
],
}
def enterprise2_sfx(edition):
@@ -894,9 +864,7 @@ def upload_packages_step(edition, ver_mode, is_downstream=False):
'image': publish_image,
'depends_on': dependencies,
'environment': {
'GCP_GRAFANA_UPLOAD_KEY': {
'from_secret': 'gcp_key',
},
'GCP_GRAFANA_UPLOAD_KEY': from_secret('gcp_key'),
},
'commands': [cmd,],
}
@@ -930,21 +898,11 @@ def publish_packages_step(edition, ver_mode, is_downstream=False):
'initialize',
],
'environment': {
'GRAFANA_COM_API_KEY': {
'from_secret': 'grafana_api_key',
},
'GCP_KEY': {
'from_secret': 'gcp_key',
},
'GPG_PRIV_KEY': {
'from_secret': 'gpg_priv_key',
},
'GPG_PUB_KEY': {
'from_secret': 'gpg_pub_key',
},
'GPG_KEY_PASSWORD': {
'from_secret': 'gpg_key_password',
},
'GRAFANA_COM_API_KEY': from_secret('grafana_api_key'),
'GCP_KEY': from_secret('gcp_key'),
'GPG_PRIV_KEY': from_secret('gpg_priv_key'),
'GPG_PUB_KEY': from_secret('gpg_pub_key'),
'GPG_KEY_PASSWORD': from_secret('gpg_key_password'),
},
'commands': [
'printenv GCP_KEY | base64 -d > /tmp/gcpkey.json',
@@ -1017,9 +975,7 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
'name': 'build-windows-installer',
'image': wix_image,
'environment': {
'GCP_KEY': {
'from_secret': 'gcp_key',
},
'GCP_KEY': from_secret('gcp_key'),
},
'commands': installer_commands,
'depends_on': [
@@ -1053,9 +1009,7 @@ def get_windows_steps(edition, ver_mode, is_downstream=False):
'name': 'clone',
'image': wix_image,
'environment': {
'GITHUB_TOKEN': {
'from_secret': 'github_token',
},
'GITHUB_TOKEN': from_secret(github_token),
},
'commands': download_grabpl_cmds + clone_cmds,
})
+2 -2
View File
@@ -29,7 +29,7 @@ load(
'publish_storybook_step',
'release_canary_npm_packages_step',
'upload_packages_step',
'deploy_to_kubernetes_step',
'push_to_deployment_tools_step',
'publish_packages_step',
'notify_pipeline',
'integration_test_services',
@@ -87,7 +87,7 @@ def get_steps(edition, is_downstream=False):
steps.extend([
release_canary_npm_packages_step(edition),
upload_packages_step(edition=edition, ver_mode=ver_mode, is_downstream=is_downstream),
deploy_to_kubernetes_step(edition=edition, is_downstream=is_downstream),
push_to_deployment_tools_step(edition=edition, is_downstream=is_downstream),
upload_cdn(edition=edition)
])
+23
View File
@@ -0,0 +1,23 @@
pull_secret = 'dockerconfigjson'
github_token = 'github_token'
def from_secret(secret):
return {
'from_secret': secret
}
def vault_secret(name, path, key):
return {
'kind': 'secret',
'name': name,
'get': {
'path': path,
'name': key,
}
}
def secrets():
return [
vault_secret(pull_secret, 'secret/data/common/gcr', '.dockerconfigjson'),
vault_secret(github_token, 'infra/data/ci/github/grafanabot', 'pat')
]