Merge branch 'dev' of github.com:grafana/oncall into dev

This commit is contained in:
Joey Orlando 2023-12-11 10:20:15 -05:00
commit 1a22db0c29
No known key found for this signature in database
GPG key ID: 469E88366B17F644
30 changed files with 358 additions and 115 deletions

View file

@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Fixed
- Fix schedules invalid dates issue ([#support-escalations/issues/8084](https://github.com/grafana/support-escalations/issues/8084))
## v1.3.76 (2023-12-11)
### Fixed
Fix minor UI bugs
## v1.3.75 (2023-12-08)
### Fixed

View file

@ -7,6 +7,7 @@ const HEARTBEAT_SETTINGS_FORM_TEST_ID = 'heartbeat-settings-form';
test.describe("updating an integration's heartbeat interval works", async () => {
const _openHeartbeatSettingsForm = async (page: Page) => {
await page.getByTestId('integration-settings-context-menu-wrapper').getByRole('img').click();
await page.waitForTimeout(1000);
await page.getByTestId('integration-heartbeat-settings').click();
};
@ -29,6 +30,8 @@ test.describe("updating an integration's heartbeat interval works", async () =>
await heartbeatSettingsForm.getByTestId('update-heartbeat').click();
await page.waitForTimeout(1000);
await _openHeartbeatSettingsForm(page);
const heartbeatIntervalValue = await heartbeatSettingsForm

View file

@ -20,13 +20,6 @@ export const createOnCallSchedule = async (page: Page, scheduleName: string, use
await clickButton({ page, buttonText: 'Add rotation' });
/**
* Drag the modal such that the "Create" button will always be visible within the viewport. We cannot scroll
* on the modal itself
* https://playwright.dev/docs/input#dragging-manually
*/
await page.locator('.ReactModal__Content .drag-handler').dragTo(page.locator('.page-header__logo'));
await selectDropdownValue({
page,
selectType: 'grafanaSelect',

View file

@ -122,7 +122,7 @@
"@grafana/data": "^9.2.4",
"@grafana/faro-web-sdk": "^1.0.0-beta4",
"@grafana/faro-web-tracing": "^1.0.0-beta4",
"@grafana/labels": "~1.4.2",
"@grafana/labels": "~1.4.3",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/ui": "^10.2.0",
"@lifeomic/attempt": "^3.0.3",

View file

@ -3,8 +3,10 @@ import React from 'react';
import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime';
import Header from 'navbar/Header/Header';
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
import { pages } from 'pages';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { DEFAULT_PAGE } from 'utils/consts';
interface AppPluginPageProps extends PluginPageProps {
page?: string;
@ -14,10 +16,14 @@ export const PluginPage = (isTopNavbar() ? RealPlugin : PluginPageFallback) as R
function RealPlugin(props: AppPluginPageProps): React.ReactNode {
const { page } = props;
const isDefaultPage = page === DEFAULT_PAGE;
return (
<RealPluginPage {...props}>
<Header />
<RenderConditionally shouldRender={isDefaultPage}>
<Header />
</RenderConditionally>
{pages[page]?.text && !pages[page]?.hideTitle && (
<h3 className="page-title" data-testid="page-title">
{pages[page].text}

View file

@ -36,12 +36,32 @@
margin-right: 4px;
}
.u-margin-bottom-none {
margin-bottom: 0;
}
.u-margin-bottom-md {
margin-bottom: 12px;
}
.u-margin-top-xs {
margin-top: 4px;
}
.u-padding-top-md {
padding-top: 12px;
}
.u-margin-bottom-none {
margin-bottom: 0;
.u-padding-top-none {
padding-top: 0;
}
.u-padding-left-lg {
padding-left: 24px;
}
.u-padding-vertical-xs {
padding: 4px 0;
}
.u-pull-right {

View file

@ -238,11 +238,13 @@ class GForm extends React.Component<GFormProps, {}> {
error={formItem.label ? `${formItem.label} is required` : `${capitalCase(formItem.name)} is required`}
description={formItem.description}
>
{onFieldRender
? onFieldRender(formItem, disabled, formControl, getValues(), (value) =>
setValue(formItem.name, value)
)
: formControl}
<div className="u-margin-top-xs">
{onFieldRender
? onFieldRender(formItem, disabled, formControl, getValues(), (value) =>
setValue(formItem.name, value)
)
: formControl}
</div>
</Field>
);
};

View file

@ -1,3 +1,5 @@
import { ReactNode } from 'react';
export enum FormItemType {
'Input' = 'input',
'Password' = 'password',
@ -13,10 +15,10 @@ export enum FormItemType {
export interface FormItem {
name: string;
label?: string;
label?: ReactNode;
type: FormItemType;
disabled?: boolean;
description?: string;
description?: ReactNode;
placeholder?: string;
normalize?: (value: any) => any;
isVisible?: (data: any) => any;

View file

@ -128,7 +128,7 @@ const IntegrationContactPoint: React.FC<{
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="xs" align="center">
<Text type="primary">Grafana Alerting Contact point</Text>
<Icon name="info-circle" className={cx('extra-fields__icon')} />
<Icon name="info-circle" />
</HorizontalGroup>
{isConnectOpen ? <Icon name="arrow-down" /> : <Icon name="arrow-right" />}

View file

@ -14,12 +14,13 @@ interface PluginLinkProps {
children: any;
query?: Record<string, any>;
target?: string;
onClick?: () => void;
}
const cx = cn.bind(styles);
const PluginLink: FC<PluginLinkProps> = (props) => {
const { children, query, disabled, className, wrap = true, target } = props;
const { children, query, disabled, className, wrap = true, target, onClick } = props;
const newPath = useMemo(() => getPathFromQueryParams(query), [query]);
@ -27,11 +28,15 @@ const PluginLink: FC<PluginLinkProps> = (props) => {
(event) => {
event.stopPropagation();
if (disabled) {
if (disabled || onClick) {
event.preventDefault();
}
if (onClick) {
onClick();
}
},
[disabled]
[disabled, onClick]
);
return (

View file

@ -1,3 +1,7 @@
import React from 'react';
import { Icon, Label, Tooltip } from '@grafana/ui';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { generateAssignToTeamInputDescription } from 'utils/consts';
@ -19,8 +23,14 @@ export const form: { name: string; fields: FormItem[] } = {
},
{
name: 'team',
label: 'Assign to team',
description: generateAssignToTeamInputDescription('Integrations'),
label: (
<Label>
<span>Assign to team</span>&nbsp;
<Tooltip content={generateAssignToTeamInputDescription('Integrations')} placement="right">
<Icon name="info-circle" />
</Tooltip>
</Label>
),
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',

View file

@ -12,7 +12,6 @@ import {
RadioButtonGroup,
Select,
Icon,
Label,
Field,
} from '@grafana/ui';
import cn from 'classnames/bind';
@ -23,6 +22,7 @@ import Collapse from 'components/Collapse/Collapse';
import Block from 'components/GBlock/Block';
import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import Labels from 'containers/Labels/Labels';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
@ -48,6 +48,7 @@ interface IntegrationFormProps {
isTableView?: boolean;
onHide: () => void;
onSubmit: () => Promise<void>;
navigateToAlertGroupLabels: (id: AlertReceiveChannel['id']) => void;
}
const IntegrationForm = observer((props: IntegrationFormProps) => {
@ -56,7 +57,7 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
const labelsRef = useRef(null);
const { id, onHide, onSubmit, isTableView = true } = props;
const { id, onHide, onSubmit, isTableView = true, navigateToAlertGroupLabels } = props;
const {
alertReceiveChannelStore,
userStore: { currentUser: user },
@ -139,7 +140,25 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
{store.hasFeature(AppFeature.Labels) && (
<div className={cx('labels')}>
<Labels ref={labelsRef} errors={errors?.labels} value={data.labels} />
<Labels
ref={labelsRef}
errors={errors?.labels}
value={data.labels}
description={
<>
Labels{id === 'new' ? ' will be ' : ' '}applied to the integration and inherited by alert
groups.
<br />
You can modify behaviour in{' '}
{id === 'new' ? (
'Alert group labeling'
) : (
<PluginLink onClick={() => navigateToAlertGroupLabels(id)}>Alert group labels</PluginLink>
)}{' '}
drawer.
</>
}
/>
</div>
)}
@ -338,8 +357,10 @@ const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = ({
<div className={cx('extra-fields')}>
<VerticalGroup spacing="md">
<HorizontalGroup spacing="xs" align="center">
<Label>Grafana Alerting Contact point</Label>
<Icon name="info-circle" className={cx('extra-fields__icon')} />
<Text type="primary" size="small">
Grafana Alerting Contact point
</Text>
<Icon name="info-circle" />
</HorizontalGroup>
<div className={cx('extra-fields__radio')}>

View file

@ -0,0 +1,41 @@
import { getIsTooManyLabelsWarningVisible } from './IntegrationLabelsForm.helpers';
describe('getIsTooManyLabelsWarningVisible()', () => {
const CUSTOM_LABEL = { key: { id: 'c', name: 'c' }, value: { id: 'c', name: 'c' } };
it('should return false if limit is not exceeded', () => {
expect(
getIsTooManyLabelsWarningVisible(
{
inheritable: undefined,
custom: undefined,
template: null,
},
3
)
).toBe(false);
expect(
getIsTooManyLabelsWarningVisible(
{
inheritable: { a: true, b: false },
custom: [CUSTOM_LABEL, CUSTOM_LABEL],
template: null,
},
3
)
).toBe(false);
});
it('should return true if limit is exceeded', () => {
expect(
getIsTooManyLabelsWarningVisible(
{
inheritable: { a: true, b: true },
custom: [CUSTOM_LABEL, CUSTOM_LABEL],
template: null,
},
3
)
).toBe(true);
});
});

View file

@ -0,0 +1,20 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
const countNumberOfInheritedAndCustomLabels = (alert_group_labels: AlertReceiveChannel['alert_group_labels']) => {
const inheritedCount = alert_group_labels.inheritable
? Object.keys(alert_group_labels.inheritable).filter((labelKey) => alert_group_labels.inheritable?.[labelKey])
.length
: 0;
const customCount = alert_group_labels.custom?.length || 0;
return inheritedCount + customCount;
};
export const getIsTooManyLabelsWarningVisible = (
alert_group_labels: AlertReceiveChannel['alert_group_labels'],
limit = 15
) => countNumberOfInheritedAndCustomLabels(alert_group_labels) > limit;
export const getIsAddBtnDisabled = ({ custom }: AlertReceiveChannel['alert_group_labels']) => {
const lastItem = custom.at(-1);
return lastItem && (lastItem?.key.id === undefined || lastItem?.value.id === undefined);
};

View file

@ -2,16 +2,14 @@ import React, { ChangeEvent, useCallback, useState } from 'react';
import { ServiceLabels } from '@grafana/labels';
import {
Alert,
Button,
Drawer,
Dropdown,
HorizontalGroup,
Icon,
InlineSwitch,
Input,
Label,
Menu,
Tooltip,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames/bind';
@ -19,12 +17,18 @@ import { observer } from 'mobx-react';
import Collapse from 'components/Collapse/Collapse';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import PluginLink from 'components/PluginLink/PluginLink';
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
import Text from 'components/Text/Text';
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelsErrors } from 'models/label/label.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { DOCS_ROOT } from 'utils/consts';
import { getIsAddBtnDisabled, getIsTooManyLabelsWarningVisible } from './IntegrationLabelsForm.helpers';
import styles from './IntegrationLabelsForm.module.css';
@ -36,15 +40,16 @@ interface IntegrationLabelsFormProps {
id: AlertReceiveChannel['id'];
onSubmit: () => void;
onHide: () => void;
onOpenIntegraionSettings: (id: AlertReceiveChannel['id']) => void;
onOpenIntegrationSettings: (id: AlertReceiveChannel['id']) => void;
}
const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
const { id, onHide, onSubmit, onOpenIntegraionSettings } = props;
const { id, onHide, onSubmit, onOpenIntegrationSettings } = props;
const store = useStore();
const [showTemplateEditor, setShowTemplateEditor] = useState<boolean>(false);
const [customLabelsErrors, setCustomLabelsErrors] = useState<LabelsErrors>([]);
const [customLabelIndexToShowTemplateEditor, setCustomLabelIndexToShowTemplateEditor] = useState<number>(undefined);
const { alertReceiveChannelStore } = store;
@ -54,18 +59,22 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels);
const handleSave = () => {
alertReceiveChannelStore.saveAlertReceiveChannel(id, { alert_group_labels: alertGroupLabels });
onSubmit();
onHide();
const handleSave = async () => {
try {
await alertReceiveChannelStore.saveAlertReceiveChannel(id, { alert_group_labels: alertGroupLabels });
onSubmit();
onHide();
} catch (err) {
if (err.response?.data?.alert_group_labels?.custom) {
setCustomLabelsErrors(err.response.data.alert_group_labels.custom);
}
}
};
const handleOpenIntegrationSettings = () => {
onHide();
onOpenIntegraionSettings(id);
onOpenIntegrationSettings(id);
};
const onInheritanceChange = (keyId: ApiSchemas['LabelKey']['id']) => {
@ -77,31 +86,54 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
return (
<>
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
<Drawer
scrollableContent
title="Alert group labeling"
subtitle={
<Text size="small" className="u-margin-top-xs">
Combination of settings that manage the labeling of alert groups. More information in{' '}
<a href={DOCS_ROOT} target="_blank" rel="noreferrer">
<Text type="link">documentation</Text>
</a>
.
</Text>
}
onClose={onHide}
closeOnMaskClick={false}
width="640px"
>
<VerticalGroup spacing="lg">
<RenderConditionally shouldRender={getIsTooManyLabelsWarningVisible(alertGroupLabels)}>
<Alert title="More than 15 labels added" severity="warning">
We support up to 15 labels per Alert group. Please remove extra labels.
<br />
Otherwise, only the first 15 labels (alphabetically sorted by keys) will be applied.
</Alert>
</RenderConditionally>
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Inherited labels</Label>
<Tooltip content="Labels inherited from integration">
<Icon name="info-circle" className={cx('extra-fields__icon')} />
</Tooltip>
</HorizontalGroup>
<Text>Integration labels</Text>
{alertReceiveChannel.labels.length ? (
<ul className={cx('labels-list')}>
{alertReceiveChannel.labels.map((label) => (
<li key={label.key.id}>
<HorizontalGroup spacing="xs">
<Input width={INPUT_WIDTH / 8} value={label.key.name} disabled />
<Input width={INPUT_WIDTH / 8} value={label.value.name} disabled />
<InlineSwitch
value={alertGroupLabels.inheritable[label.key.id]}
transparent
onChange={() => onInheritanceChange(label.key.id)}
/>
</HorizontalGroup>
</li>
))}
</ul>
<VerticalGroup spacing="xs">
<Text type="secondary" size="small">
Labels inherited from <PluginLink onClick={handleOpenIntegrationSettings}>the integration</PluginLink>
. This behavior can be disabled using the toggle option.
</Text>
<ul className={cx('labels-list')}>
{alertReceiveChannel.labels.map((label) => (
<li key={label.key.id}>
<HorizontalGroup spacing="xs">
<Input width={INPUT_WIDTH / 8} value={label.key.name} disabled />
<Input width={INPUT_WIDTH / 8} value={label.value.name} disabled />
<InlineSwitch
value={alertGroupLabels.inheritable[label.key.id]}
transparent
onChange={() => onInheritanceChange(label.key.id)}
/>
</HorizontalGroup>
</li>
))}
</ul>
</VerticalGroup>
) : (
<VerticalGroup>
<Text type="secondary">There are no labels to inherit yet</Text>
@ -114,14 +146,22 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
<CustomLabels
alertGroupLabels={alertGroupLabels}
onChange={setAlertGroupLabels}
onChange={(val) => {
setCustomLabelsErrors([]);
setAlertGroupLabels(val);
}}
onShowTemplateEditor={setCustomLabelIndexToShowTemplateEditor}
customLabelsErrors={customLabelsErrors}
/>
<Collapse isOpen={false} label="Advanced label templating">
<Collapse isOpen={false} label="Multi-label extraction template" contentClassName="u-padding-top-none">
<VerticalGroup>
<HorizontalGroup justify="space-between" style={{ marginBottom: '10px' }}>
<Text type="secondary">Jinja2 template to parse all labels at once</Text>
<Text type="secondary" size="small" className="u-padding-left-lg">
Allows for the extraction and modification of multiple labels from the alert payload using a single
template. Supports not only dynamic values but also dynamic keys. The Jinja template must result in
valid JSON dictionary.
</Text>
<Button
variant="secondary"
icon="edit"
@ -204,16 +244,17 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
interface CustomLabelsProps {
alertGroupLabels: AlertReceiveChannel['alert_group_labels'];
customLabelsErrors: LabelsErrors;
onChange: (value: AlertReceiveChannel['alert_group_labels']) => void;
onShowTemplateEditor: (index: number) => void;
}
const CustomLabels = (props: CustomLabelsProps) => {
const { alertGroupLabels, onChange, onShowTemplateEditor } = props;
const { alertGroupLabels, onChange, onShowTemplateEditor, customLabelsErrors } = props;
const { labelsStore } = useStore();
const handlePlainLabelAdd = () => {
const handleStaticLabelAdd = () => {
onChange({
...alertGroupLabels,
custom: [
@ -225,7 +266,7 @@ const CustomLabels = (props: CustomLabelsProps) => {
],
});
};
const handleTemplatedLabelAdd = () => {
const handleDynamicLabelAdd = () => {
onChange({
...alertGroupLabels,
custom: [
@ -271,13 +312,19 @@ const CustomLabels = (props: CustomLabelsProps) => {
return (
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Custom labels</Label>
</HorizontalGroup>
<Text>Dynamic & Static labels</Text>
<Text type="secondary" size="small">
Dynamic: label values are extracted from the alert payload using Jinja. Keys remain static.
<br />
Static: these are not derived from the payload; both key and value are static.
<br />
These labels will not be attached to the integration.
</Text>
<ServiceLabels
isAddingDisabled
loadById
inputWidth={INPUT_WIDTH}
errors={customLabelsErrors}
value={alertGroupLabels.custom}
onLoadKeys={cachedOnLoadKeys()}
onLoadValuesForKey={cachedOnLoadValuesForKey()}
@ -332,13 +379,13 @@ const CustomLabels = (props: CustomLabelsProps) => {
<Dropdown
overlay={
<Menu>
<Menu.Item label="Plain label" onClick={handlePlainLabelAdd} />
<Menu.Item label="Templated label" onClick={handleTemplatedLabelAdd} />
<Menu.Item label="Static label" onClick={handleStaticLabelAdd} />
<Menu.Item label="Dynamic label" onClick={handleDynamicLabelAdd} />
</Menu>
}
>
<Button variant="secondary" icon="plus">
Add
<Button variant="secondary" icon="plus" disabled={getIsAddBtnDisabled(alertGroupLabels)}>
Add label
</Button>
</Dropdown>
</VerticalGroup>

View file

@ -1,7 +1,7 @@
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import { ServiceLabels, ServiceLabelsProps } from '@grafana/labels';
import { Field } from '@grafana/ui';
import { Field, Label } from '@grafana/ui';
import { isEmpty } from 'lodash-es';
import { observer } from 'mobx-react';
@ -13,11 +13,12 @@ export interface LabelsProps {
value: LabelKeyValue[];
errors: any;
onDataUpdate?: ServiceLabelsProps['onDataUpdate'];
description?: React.ComponentProps<typeof Label>['description'];
}
const Labels = observer(
forwardRef(function Labels2(props: LabelsProps, ref) {
const { value: defaultValue, errors: propsErrors, onDataUpdate } = props;
const { value: defaultValue, errors: propsErrors, onDataUpdate, description } = props;
// propsErrors are 'external' caused by attaching/detaching labels to oncall entities,
// state errors are errors caused by CRUD operations on labels storage
@ -103,7 +104,7 @@ const Labels = observer(
return (
<div>
<Field label="Labels">
<Field label={<Label description={<div className="u-padding-vertical-xs">{description}</div>}>Labels</Label>}>
<ServiceLabels
loadById
value={value}
@ -132,4 +133,4 @@ function onUpdateError(res) {
}
}
export default Labels;
export default React.memo(Labels);

View file

@ -57,22 +57,27 @@ export const WebhookTabs = {
LastRun: new KeyValuePair('LastRun', 'Last Run'),
};
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(
({ errors, setValue, getValues }) => {
const { hasFeature } = useStore();
const onDataUpdate: LabelsProps['onDataUpdate'] = (val) => setValue(WebhookFormFieldName.Labels, val);
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(({ setValue, getValues }) => {
const {
hasFeature,
outgoingWebhookStore: { labelsFormErrors },
} = useStore();
const onDataUpdate: LabelsProps['onDataUpdate'] = useCallback(
(val) => setValue(WebhookFormFieldName.Labels, val),
[]
);
return (
<RenderConditionally shouldRender={hasFeature(AppFeature.Labels)}>
<Labels
value={getValues<LabelKeyValue[]>(WebhookFormFieldName.Labels) || []}
errors={errors?.[WebhookFormFieldName.Labels]}
onDataUpdate={onDataUpdate}
/>
</RenderConditionally>
);
}
);
return (
<RenderConditionally shouldRender={hasFeature(AppFeature.Labels)}>
<Labels
value={getValues<LabelKeyValue[]>(WebhookFormFieldName.Labels) || []}
errors={labelsFormErrors}
onDataUpdate={onDataUpdate}
description="Labels applied to the webhook will be included in the webhook payload, along with alert group and integration labels."
/>
</RenderConditionally>
);
});
const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
const history = useHistory();
@ -93,11 +98,21 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
const handleSubmit = useCallback(
(data: Partial<OutgoingWebhook>) => {
(isNewOrCopy ? outgoingWebhookStore.create(data) : outgoingWebhookStore.update(id, data)).then(() => {
async (data: Partial<OutgoingWebhook>) => {
try {
if (isNewOrCopy) {
await outgoingWebhookStore.create(data);
} else {
await outgoingWebhookStore.update(id, data);
}
outgoingWebhookStore.setLabelsFormErrors(undefined);
onHide();
onUpdate();
});
} catch (err) {
if (err.response?.data?.labels) {
outgoingWebhookStore.setLabelsFormErrors(err.response.data.labels);
}
}
},
[id]
);

View file

@ -336,7 +336,7 @@ export class AlertReceiveChannelStore extends BaseStore {
@action.bound
@WithGlobalNotification({ success: 'Integration has been saved', failure: 'Failed to save integration' })
async saveAlertReceiveChannel(id: AlertReceiveChannel['id'], data: Partial<AlertReceiveChannel>) {
const item = await this.update(id, data);
const item = await this.update(id, data, undefined, true);
this.items = {
...this.items,

View file

@ -20,13 +20,23 @@ export default class BaseStore {
if (error.response.status >= 400 && error.response.status < 500) {
const payload = error.response.data;
const text =
typeof payload === 'string'
? payload
: Object.keys(payload)
.map((key) => `${sentenceCase(key)}: ${payload[key]}`)
.map((key) => {
const candidate = `${sentenceCase(key)}: ${payload[key]}`;
if (candidate.includes('object Object')) {
return undefined;
}
return candidate;
})
.join('\n');
openWarningNotification(text);
if (text?.length) {
openWarningNotification(text);
}
}
throw error;

View file

@ -4,3 +4,5 @@ export interface LabelKeyValue {
key: ApiSchemas['LabelKey'];
value: ApiSchemas['LabelValue'];
}
export type LabelsErrors = Array<{ key?: { id: string[]; name: string[] }; value?: { id: string[]; name: string[] } }>;

View file

@ -1,6 +1,7 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { LabelsErrors } from 'models/label/label.types';
import { makeRequest } from 'network';
import { RootStore } from 'state';
@ -16,6 +17,9 @@ export class OutgoingWebhookStore extends BaseStore {
@observable.shallow
outgoingWebhookPresets: OutgoingWebhookPreset[] = [];
@observable
labelsFormErrors?: LabelsErrors;
constructor(rootStore: RootStore) {
super(rootStore);
@ -106,4 +110,9 @@ export class OutgoingWebhookStore extends BaseStore {
const response = await makeRequest(`/webhooks/preset_options/`, {});
this.outgoingWebhookPresets = response;
}
@action.bound
setLabelsFormErrors(errors: LabelsErrors) {
this.labelsFormErrors = errors;
}
}

View file

@ -4,7 +4,7 @@
.header-topnavbar {
padding-top: 0;
padding-bottom: 36px;
padding-bottom: 12px;
}
.navbar-heading {
@ -34,6 +34,7 @@
flex-direction: row;
column-gap: 8px;
row-gap: 8px;
margin-left: -50px;
}
.irm-icon {
@ -52,3 +53,12 @@
margin-bottom: 0;
}
}
.logo-container,
.page-header__img {
height: 32px;
}
.page-header__title {
margin-bottom: 8px;
}

View file

@ -23,8 +23,8 @@ const Header = observer(() => {
<div className={cx('root')}>
<div className={cx('page-header__inner', { 'header-topnavbar': isTopNavbar() })}>
<div className={cx('navbar-left')}>
<span className="page-header__logo">
<img className="page-header__img" src={logo} alt="Grafana OnCall" />
<span className={cx('page-header__logo', 'logo-container')}>
<img className={cx('page-header__img')} src={logo} alt="Grafana OnCall" />
</span>
<div className="page-header__info-block">{renderHeading()}</div>
</div>
@ -41,6 +41,7 @@ const Header = observer(() => {
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
<div className={cx('navbar-heading-container')}>
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
<Card heading={undefined} className={cx('navbar-heading')}>
<a
href="https://github.com/grafana/oncall"

View file

@ -790,6 +790,10 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
onHide={() => setIsIntegrationSettingsOpen(false)}
onSubmit={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
id={alertReceiveChannel['id']}
navigateToAlertGroupLabels={(_id: AlertReceiveChannel['id']) => {
setIsIntegrationSettingsOpen(false);
setLabelsFormOpen(true);
}}
/>
)}
@ -800,7 +804,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
}}
onSubmit={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
id={alertReceiveChannel['id']}
onOpenIntegraionSettings={() => {
onOpenIntegrationSettings={() => {
setIsIntegrationSettingsOpen(true);
}}
/>
@ -846,7 +850,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => openLabelsForm()}>
<Text type="primary">Alert group labels</Text>
<Text type="primary">Alert group labeling</Text>
</div>
</WithPermissionControlTooltip>
)}

View file

@ -7,6 +7,10 @@
width: 40px;
}
.title {
margin-bottom: 16px;
}
.tabsBar {
margin-bottom: 24px;
}

View file

@ -294,6 +294,9 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}}
onSubmit={this.update}
id={alertReceiveChannelId}
navigateToAlertGroupLabels={(id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelId: undefined, alertReceiveChannelIdToShowLabels: id });
}}
/>
)}
@ -304,7 +307,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}}
onSubmit={this.update}
id={alertReceiveChannelIdToShowLabels}
onOpenIntegraionSettings={(id: AlertReceiveChannel['id']) => {
onOpenIntegrationSettings={(id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelId: id });
}}
/>
@ -495,7 +498,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<div className={cx('integrations-actionItem')} onClick={() => this.onLabelsEditClick(item.id)}>
<Text type="primary">Alert group labels</Text>
<Text type="primary">Alert group labeling</Text>
</div>
</WithPermissionControlTooltip>
)}

View file

@ -16,10 +16,13 @@ const mondayDayOffset = {
};
export const getWeekStartString = () => {
if (!config.bootData.user.weekStart || config.bootData.user.weekStart === 'browser') {
const weekStart = (config.bootData.user.weekStart || '').toLowerCase();
if (!weekStart || weekStart === 'browser') {
return 'monday';
}
return config.bootData.user.weekStart;
return weekStart;
};
export const getNow = (tz: Timezone) => {

View file

@ -38,6 +38,7 @@ export const FARO_ENDPOINT_OPS =
export const FARO_ENDPOINT_PROD =
'https://faro-collector-prod-us-central-0.grafana.net/collect/03a11ed03c3af04dcfc3be9755f2b053';
export const DOCS_ROOT = 'https://grafana.com/docs/oncall/latest';
export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/oncall/latest/open-source/#slack-setup';
export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notify/telegram/';

View file

@ -52,7 +52,7 @@ export function WithGlobalNotification({
const open = failureType === 'error' ? openErrorNotification : openWarningNotification;
const message = composeFailureMessageFn ? composeFailureMessageFn(err) : failure;
open(message);
throw new Error(err);
throw err;
}
};
};

View file

@ -2027,10 +2027,10 @@
"@opentelemetry/sdk-trace-web" "^1.8.0"
"@opentelemetry/semantic-conventions" "^1.8.0"
"@grafana/labels@~1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.4.2.tgz#3ce4fb4e06c86793df85622de9fd47793261a849"
integrity sha512-4d/+SnLxxBGCYGZI/BAtF1s6M/K5cxFmOEDmUORBs5sXTiUXHsvNXbsh1ACfcH/wCUlbypYY6FQULSAWs6pOeQ==
"@grafana/labels@~1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.4.3.tgz#1103ef41341c84cac8d7d7e0b36d4671d20311b6"
integrity sha512-ImmkKERHkbDqakjgFN1Tl6FmwQa+7/YTyV+G8vBtX6HlNWIPGso7glNuHOEvMdvhz1fuJgSFEQ9+nggZv1TW4g==
dependencies:
"@emotion/css" "^11.11.2"
"@grafana/ui" "^10.0.0"