Use autogenerated types for alert_receive_channels (#3851)

# What this PR does

- autogenerate new types exposed by backend, remove custom types that
duplicate autogenerated ones
- use autogenerated types for alert receive channels
- in alert_receive_channel model:
  - use autogenerate http client (`onCallApi`) for http requests
- extract methods that don't update state into
alert_receive_channel.helpers.ts and make them pure (they accept
AlertReceiveChannelStore as param) to avoid inconsistency and issues
with `this` binding
  - use `makeAutoObservable`
  - remove unneeded decorators
- rename update* methods to fetch* whenever such methods retrieve data
from backend with GET requests
- in other models use `@action.bound` for actions and arrow functions
for store methods that are not actions (in subsequent PRs we will apply
the same changes as in alert_receive_channel, this is just for now until
we do it)
- refactor http-client so that it shows global notification on http
errors automatically and provide the possibility to opt-out from it when
making a call
- improve type-safety of `GSelect`
- fix bug related to attaching alert group
(https://raintank-corp.slack.com/archives/C04JCU51NF8/p1707476487580579)

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/3331

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
This commit is contained in:
Dominik Broj 2024-02-20 13:09:22 +01:00 committed by GitHub
parent 8f02d8513c
commit 6da36b3c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 3914 additions and 1247 deletions

View file

@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Check for permissions on Slack escalate command ([#3891](https://github.com/grafana/oncall/pull/3891))
- Use autogenerated types on the frontend for alert receive channels ([#3331](https://github.com/grafana/oncall/issues/3331))
- Update OnCall Insights dashboard @Ferril ([#3875](https://github.com/grafana/oncall/pull/3875))
- Do not delete webhook if its team is deleted @mderynck ([#3873](https://github.com/grafana/oncall/pull/3873))
- Update user details internal API perms ([#3900](https://github.com/grafana/oncall/pull/3900))

View file

@ -518,11 +518,11 @@ In order to automate types creation and prevent API usage pitfalls, OnCall proje
```ts
import { ApiSchemas } from "network/oncall-api/api.types";
import onCallApi from "network/oncall-api/http-client";
import { onCallApi } from "network/oncall-api/http-client";
const {
data: { results },
} = await onCallApi.GET("/alertgroups/");
} = await onCallApi().GET("/alertgroups/");
const alertGroups: Array<ApiSchemas["AlertGroup"]> = results;
```

View file

@ -241,7 +241,7 @@ class AlertReceiveChannelSerializer(
demo_alert_payload = serializers.JSONField(source="config.example_payload", read_only=True)
routes_count = serializers.SerializerMethodField()
connected_escalations_chains_count = serializers.SerializerMethodField()
inbound_email = serializers.CharField(required=False)
inbound_email = serializers.CharField(required=False, read_only=True)
is_legacy = serializers.SerializerMethodField()
alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False)

View file

@ -511,7 +511,11 @@ class AlertReceiveChannelView(
fields={
"uid": serializers.CharField(),
"name": serializers.CharField(),
"contact_points": serializers.ListField(child=serializers.CharField()),
"contact_points": inline_serializer(
"AlertReceiveChannelConnectedContactPointsInner",
fields={"name": serializers.CharField(), "notification_connected": serializers.BooleanField()},
many=True,
),
},
many=True,
)

View file

@ -15,6 +15,6 @@ yarn-error.log*
grafana-plugin.yml
# playwright
/playwright-report/
/playwright-report*
/playwright/.cache/
/e2e-tests/storageState.json

View file

@ -27,7 +27,8 @@
"ci-report": "grafana-toolkit plugin:ci-report",
"start": "yarn watch",
"plop": "plop",
"setversion": "setversion"
"setversion": "setversion",
"typecheck": "tsc --noEmit"
},
"repository": {
"type": "git",

View file

@ -21,7 +21,9 @@ import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { Tag } from 'components/Tag/Tag';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { AlertReceiveChannel, ContactPoint } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ContactPoint } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import styles from 'pages/integration/Integration.module.scss';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
@ -46,7 +48,7 @@ interface IntegrationContactPointState {
}
export const IntegrationContactPoint: React.FC<{
id: AlertReceiveChannel['id'];
id: ApiSchemas['AlertReceiveChannel']['id'];
}> = observer(({ id }) => {
const { alertReceiveChannelStore } = useStore();
const contactPoints = alertReceiveChannelStore.connectedContactPoints[id];
@ -84,7 +86,7 @@ export const IntegrationContactPoint: React.FC<{
useEffect(() => {
(async function () {
const response = await alertReceiveChannelStore.getGrafanaAlertingContactPoints();
const response = await AlertReceiveChannelHelper.getGrafanaAlertingContactPoints();
setState({
allContactPoints: response,
dataSourceOptions: response.map((res) => ({ label: res.name, value: res.uid })),
@ -281,12 +283,11 @@ export const IntegrationContactPoint: React.FC<{
aria-label="Disconnect Contact Point"
name="trash-alt"
onClick={() => {
alertReceiveChannelStore
.disconnectContactPoint(id, item.dataSourceId, item.contactPoint)
AlertReceiveChannelHelper.disconnectContactPoint(id, item.dataSourceId, item.contactPoint)
.then(() => {
closeDrawer();
openNotification('Contact point has been removed');
alertReceiveChannelStore.updateConnectedContactPoints(id);
alertReceiveChannelStore.fetchConnectedContactPoints(id);
})
.catch(() => openErrorNotification('An error has occurred. Please try again.'));
}}
@ -338,13 +339,13 @@ export const IntegrationContactPoint: React.FC<{
setState({ isLoading: true });
(isExistingContactPoint
? alertReceiveChannelStore.connectContactPoint(id, selectedAlertManager, selectedContactPoint)
: alertReceiveChannelStore.createContactPoint(id, selectedAlertManager, selectedContactPoint)
? AlertReceiveChannelHelper.connectContactPoint(id, selectedAlertManager, selectedContactPoint)
: AlertReceiveChannelHelper.createContactPoint(id, selectedAlertManager, selectedContactPoint)
)
.then(() => {
closeDrawer();
openNotification('A new contact point has been connected to your integration');
alertReceiveChannelStore.updateConnectedContactPoints(id);
alertReceiveChannelStore.fetchConnectedContactPoints(id);
})
.catch((ex) => {
const error = ex.response?.data?.detail ?? 'An error has occurred. Please try again.';

View file

@ -8,14 +8,14 @@ import { IntegrationInputField } from 'components/IntegrationInputField/Integrat
import { IntegrationBlock } from 'components/Integrations/IntegrationBlock';
import { Tag } from 'components/Tag/Tag';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import styles from 'pages/integration/Integration.module.scss';
import { useStore } from 'state/useStore';
import { getVar } from 'utils/DOM';
const cx = cn.bind(styles);
export const IntegrationHowToConnect: React.FC<{ id: AlertReceiveChannel['id'] }> = ({ id }) => {
export const IntegrationHowToConnect: React.FC<{ id: ApiSchemas['AlertReceiveChannel']['id'] }> = ({ id }) => {
const { alertReceiveChannelStore } = useStore();
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const hasAlerts = !!alertReceiveChannelCounter?.alerts_count;

View file

@ -10,7 +10,8 @@ import { MonacoEditor, MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEdi
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import styles from 'pages/integration/Integration.module.scss';
import { useStore } from 'state/useStore';
import { openNotification } from 'utils/utils';
@ -19,7 +20,7 @@ const cx = cn.bind(styles);
interface IntegrationSendDemoPayloadModalProps {
isOpen: boolean;
alertReceiveChannel: AlertReceiveChannel;
alertReceiveChannel: ApiSchemas['AlertReceiveChannel'];
onHideOrCancel: () => void;
}
@ -88,7 +89,7 @@ export const IntegrationSendDemoAlertModal: React.FC<IntegrationSendDemoPayloadM
<CopyToClipboard text={getCurlText()} onCopy={() => openNotification('CURL has been copied')}>
<Button variant={'secondary'}>Copy as CURL</Button>
</CopyToClipboard>
<Button variant={'primary'} onClick={sendDemoAlert} data-testid="submit-send-alert">
<Button variant={'primary'} onClick={onSendAlert} data-testid="submit-send-alert">
Send Alert
</Button>
</HorizontalGroup>
@ -100,14 +101,14 @@ export const IntegrationSendDemoAlertModal: React.FC<IntegrationSendDemoPayloadM
setDemoPayload(value);
}
function sendDemoAlert() {
function onSendAlert() {
let parsedPayload = undefined;
try {
parsedPayload = JSON.parse(demoPayload);
} catch (ex) {}
alertReceiveChannelStore.sendDemoAlert(alertReceiveChannel.id, parsedPayload).then(() => {
alertReceiveChannelStore.updateCounters();
AlertReceiveChannelHelper.sendDemoAlert(alertReceiveChannel.id, parsedPayload).then(() => {
alertReceiveChannelStore.fetchCounters();
openNotification(<DemoNotification />);
onHideOrCancel();
});

View file

@ -20,10 +20,12 @@ import {
EscalationPolicy as EscalationPolicyType,
EscalationPolicyOption,
} from 'models/escalation_policy/escalation_policy.types';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { ScheduleStore } from 'models/schedule/schedule';
import { SelectOption } from 'state/types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { Schedule } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
import { UserGroup } from 'models/user_group/user_group.types';
import { SelectOption, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { getVar } from 'utils/DOM';
import { UserActions } from 'utils/authorization/authorization';
@ -34,7 +36,7 @@ import styles from './EscalationPolicy.module.css';
const cx = cn.bind(styles);
interface ElementSortableProps {
interface ElementSortableProps extends WithStoreProps {
index: number;
}
@ -51,9 +53,6 @@ export interface EscalationPolicyProps extends ElementSortableProps {
backgroundClassName?: string;
backgroundHexNumber?: string;
isSlackInstalled: boolean;
teamStore: GrafanaTeamStore;
outgoingWebhookStore: OutgoingWebhookStore;
scheduleStore: ScheduleStore;
}
class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
@ -81,13 +80,13 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
)}
{escalationOption &&
reactStringReplace(escalationOption.display_name, /\{\{([^}]+)\}\}/g, this.replacePlaceholder)}
{this._renderNote()}
{this.renderNote()}
{is_final || isDisabled ? null : (
<WithPermissionControlTooltip className={cx('delete')} userAction={UserActions.EscalationChainsWrite}>
<IconButton
name="trash-alt"
className={cx('delete', 'control')}
onClick={this._handleDelete}
onClick={this.handleDelete}
size="sm"
tooltip="Delete"
tooltipPlacement="top"
@ -105,15 +104,15 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
case 'timerange':
return this.renderTimeRange();
case 'users':
return this._renderNotifyToUsersQueue();
return this.renderNotifyToUsersQueue();
case 'wait_delay':
return this._renderWaitDelays();
return this.renderWaitDelays();
case 'slack_user_group':
return this._renderNotifyUserGroup();
return this.renderNotifyUserGroup();
case 'schedule':
return this._renderNotifySchedule();
return this.renderNotifySchedule();
case 'custom_webhook':
return this._renderTriggerCustomWebhook();
return this.renderTriggerCustomWebhook();
case 'num_alerts_in_window':
return this.renderNumAlertsInWindow();
case 'num_minutes_in_window':
@ -124,7 +123,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
}
};
_renderNote() {
renderNote() {
const { data, isSlackInstalled, escalationChoices } = this.props;
const { step } = data;
@ -152,32 +151,39 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
}
}
private _renderNotifyToUsersQueue() {
const { data, isDisabled } = this.props;
renderNotifyToUsersQueue() {
const {
data,
isDisabled,
store: { userStore },
} = this.props;
const { notify_to_users_queue } = data;
return (
<WithPermissionControlTooltip key="users-multiple" userAction={UserActions.EscalationChainsWrite}>
<GSelect
<GSelect<User>
isMulti
showSearch
allowClear
disabled={isDisabled}
modelName="userStore"
displayField="username"
valueField="pk"
placeholder="Select Users"
className={cx('select', 'control', 'multiSelect')}
value={notify_to_users_queue}
onChange={this._getOnChangeHandler('notify_to_users_queue')}
onChange={this.getOnChangeHandler('notify_to_users_queue')}
getOptionLabel={({ value }: SelectableValue) => <UserTooltip id={value} />}
width={'auto'}
items={userStore.items}
fetchItemsFn={userStore.updateItems}
fetchItemFn={userStore.updateItem}
getSearchResult={userStore.getSearchResult}
/>
</WithPermissionControlTooltip>
);
}
private renderImportance() {
renderImportance() {
const { data, isDisabled } = this.props;
const { important } = data;
@ -189,7 +195,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
disabled={isDisabled}
value={Number(important)}
// @ts-ignore
onChange={this._getOnSelectChangeHandler('important')}
onChange={this.getOnSelectChangeHandler('important')}
options={[
{
value: 0,
@ -222,7 +228,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
private renderTimeRange() {
renderTimeRange() {
const { data, isDisabled } = this.props;
return (
@ -231,14 +237,14 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
from={data.from_time}
to={data.to_time}
disabled={isDisabled}
onChange={this._getOnTimeRangeChangeHandler()}
onChange={this.getOnTimeRangeChangeHandler()}
className={cx('select', 'control')}
/>
</WithPermissionControlTooltip>
);
}
private _renderWaitDelays() {
renderWaitDelays() {
const { data, isDisabled, waitDelays = [] } = this.props;
const { wait_delay } = data;
@ -251,7 +257,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
className={cx('select', 'control')}
// @ts-ignore
value={wait_delay}
onChange={this._getOnSelectChangeHandler('wait_delay')}
onChange={this.getOnSelectChangeHandler('wait_delay')}
options={waitDelays.map((waitDelay: SelectOption) => ({
value: waitDelay.value,
label: waitDelay.display_name,
@ -262,7 +268,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
private renderNumAlertsInWindow() {
renderNumAlertsInWindow() {
const { data, isDisabled } = this.props;
const { num_alerts_in_window } = data;
@ -273,7 +279,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
disabled={isDisabled}
className={cx('control')}
value={num_alerts_in_window}
onChange={this._getOnInputChangeHandler('num_alerts_in_window')}
onChange={this.getOnInputChangeHandler('num_alerts_in_window')}
ref={(node) => {
if (node) {
node.setAttribute('type', 'number');
@ -285,7 +291,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
private renderNumMinutesInWindowOptions() {
renderNumMinutesInWindowOptions() {
const { data, isDisabled, numMinutesInWindowOptions = [] } = this.props;
const { num_minutes_in_window } = data;
@ -298,7 +304,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
className={cx('select', 'control')}
// @ts-ignore
value={num_minutes_in_window}
onChange={this._getOnSelectChangeHandler('num_minutes_in_window')}
onChange={this.getOnSelectChangeHandler('num_minutes_in_window')}
options={numMinutesInWindowOptions.map((waitDelay: SelectOption) => ({
value: waitDelay.value,
label: waitDelay.display_name,
@ -308,25 +314,31 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
private _renderNotifySchedule() {
const { data, isDisabled, teamStore, scheduleStore } = this.props;
renderNotifySchedule() {
const {
data,
isDisabled,
store: { grafanaTeamStore, scheduleStore },
} = this.props;
const { notify_schedule } = data;
return (
<WithPermissionControlTooltip key="notify_schedule" userAction={UserActions.EscalationChainsWrite}>
<GSelect
<GSelect<Schedule>
showSearch
allowClear
disabled={isDisabled}
modelName="scheduleStore"
items={scheduleStore.items}
fetchItemsFn={scheduleStore.updateItems}
getSearchResult={scheduleStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select Schedule"
className={cx('select', 'control')}
value={notify_schedule}
onChange={this._getOnChangeHandler('notify_schedule')}
onChange={this.getOnChangeHandler('notify_schedule')}
getOptionLabel={(item: SelectableValue) => {
const team = teamStore.items[scheduleStore.items[item.value].team];
const team = grafanaTeamStore.items[scheduleStore.items[item.value].team];
return (
<>
<Text>{item.label} </Text>
@ -339,45 +351,58 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
private _renderNotifyUserGroup() {
const { data, isDisabled } = this.props;
renderNotifyUserGroup() {
const {
data,
isDisabled,
store: { userGroupStore },
} = this.props;
const { notify_to_group } = data;
return (
<WithPermissionControlTooltip key="notify_to_group" userAction={UserActions.EscalationChainsWrite}>
<GSelect
<GSelect<UserGroup[]>
disabled={isDisabled}
modelName="userGroupStore"
items={userGroupStore.items}
fetchItemsFn={userGroupStore.updateItems}
getSearchResult={userGroupStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select User Group"
className={cx('select', 'control')}
value={notify_to_group}
onChange={this._getOnChangeHandler('notify_to_group')}
onChange={this.getOnChangeHandler('notify_to_group')}
width={'auto'}
/>
</WithPermissionControlTooltip>
);
}
private _renderTriggerCustomWebhook() {
const { data, isDisabled, teamStore, outgoingWebhookStore } = this.props;
renderTriggerCustomWebhook() {
const {
data,
isDisabled,
store: { grafanaTeamStore, outgoingWebhookStore },
} = this.props;
const { custom_webhook } = data;
return (
<WithPermissionControlTooltip key="custom-webhook" userAction={UserActions.EscalationChainsWrite}>
<GSelect
<GSelect<OutgoingWebhook>
showSearch
disabled={isDisabled}
modelName="outgoingWebhookStore"
items={outgoingWebhookStore.items}
fetchItemsFn={outgoingWebhookStore.updateItems}
fetchItemFn={outgoingWebhookStore.updateItem}
getSearchResult={outgoingWebhookStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select Webhook"
className={cx('select', 'control')}
value={custom_webhook}
onChange={this._getOnChangeHandler('custom_webhook')}
onChange={this.getOnChangeHandler('custom_webhook')}
getOptionLabel={(item: SelectableValue) => {
const team = teamStore.items[outgoingWebhookStore.items[item.value].team];
const team = grafanaTeamStore.items[outgoingWebhookStore.items[item.value].team];
return (
<>
<Text>{item.label} </Text>
@ -395,7 +420,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
);
}
_getOnSelectChangeHandler = (field: string) => {
getOnSelectChangeHandler = (field: string) => {
return (option: SelectableValue) => {
const { data, onChange = () => {} } = this.props;
const { id } = data;
@ -409,7 +434,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
};
};
_getOnInputChangeHandler = (field: string) => {
getOnInputChangeHandler = (field: string) => {
const { data, onChange = () => {} } = this.props;
const { id } = data;
@ -423,7 +448,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
};
};
_getOnChangeHandler = (field: string) => {
getOnChangeHandler = (field: string) => {
return (value: any) => {
const { data, onChange = () => {} } = this.props;
const { id } = data;
@ -437,7 +462,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
};
};
_getOnTimeRangeChangeHandler() {
getOnTimeRangeChangeHandler() {
return (value: string[]) => {
const { data, onChange = () => {} } = this.props;
const { id } = data;
@ -452,11 +477,13 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
};
}
_handleDelete = () => {
handleDelete = () => {
const { onDelete, data } = this.props;
onDelete(data);
};
}
export const EscalationPolicy = SortableElement(_EscalationPolicy) as React.ComponentClass<EscalationPolicyProps>;
export const EscalationPolicy = withMobXProviderContext(
SortableElement(_EscalationPolicy) as React.ComponentClass<EscalationPolicyProps>
);

View file

@ -11,7 +11,8 @@ import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { TeamName } from 'containers/TeamName/TeamName';
import { HeartGreenIcon, HeartRedIcon } from 'icons/Icons';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import styles from './AlertReceiveChannelCard.module.scss';
@ -19,7 +20,7 @@ import styles from './AlertReceiveChannelCard.module.scss';
const cx = cn.bind(styles);
interface AlertReceiveChannelCardProps {
id: AlertReceiveChannel['id'];
id: ApiSchemas['AlertReceiveChannel']['id'];
onShowHeartbeatModal: () => void;
}
@ -38,7 +39,7 @@ export const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardP
const heartbeatStatus = Boolean(heartbeat?.status);
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
return (
<div className={cx('root')}>

View file

@ -22,7 +22,7 @@ export const MSTeamsConnector = (props: MSTeamsConnectorProps) => {
const { channelFilterId } = props;
const store = useStore();
const { alertReceiveChannelStore } = store;
const { alertReceiveChannelStore, msteamsChannelStore } = store;
const channelFilter = store.alertReceiveChannelStore.channelFilters[channelFilterId];
@ -54,11 +54,13 @@ export const MSTeamsConnector = (props: MSTeamsConnectorProps) => {
</div>
Post to Microsoft Teams channel
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<GSelect
<GSelect<MSTeamsChannel>
showSearch
allowClear
className={cx('select', 'control')}
modelName="msteamsChannelStore"
items={msteamsChannelStore.items}
fetchItemsFn={msteamsChannelStore.updateItems}
getSearchResult={msteamsChannelStore.getSearchResult}
displayField="display_name"
valueField="id"
placeholder="Select Microsoft Teams Channel"

View file

@ -29,6 +29,7 @@ export const SlackConnector = (props: SlackConnectorProps) => {
const {
organizationStore: { currentOrganization },
alertReceiveChannelStore,
slackChannelStore,
} = store;
const channelFilter = store.alertReceiveChannelStore.channelFilters[channelFilterId];
@ -56,11 +57,14 @@ export const SlackConnector = (props: SlackConnectorProps) => {
</div>
Slack Channel
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<GSelect
<GSelect<SlackChannel>
showSearch
allowClear
className={cx('select', 'control')}
modelName="slackChannelStore"
items={slackChannelStore.items}
fetchItemsFn={slackChannelStore.updateItems}
fetchItemFn={slackChannelStore.updateItem}
getSearchResult={slackChannelStore.getSearchResult}
displayField="display_name"
valueField="id"
placeholder="Select Slack Channel"

View file

@ -20,7 +20,7 @@ interface TelegramConnectorProps {
export const TelegramConnector = ({ channelFilterId }: TelegramConnectorProps) => {
const store = useStore();
const { alertReceiveChannelStore } = store;
const { alertReceiveChannelStore, telegramChannelStore } = store;
const channelFilter = store.alertReceiveChannelStore.channelFilters[channelFilterId];
@ -46,11 +46,13 @@ export const TelegramConnector = ({ channelFilterId }: TelegramConnectorProps) =
</div>
Post to telegram channel
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<GSelect
<GSelect<TelegramChannel>
showSearch
allowClear
className={cx('select', 'control')}
modelName="telegramChannelStore"
items={telegramChannelStore.items}
fetchItemsFn={telegramChannelStore.updateItems}
getSearchResult={telegramChannelStore.getSearchResult}
displayField="channel_name"
valueField="id"
placeholder="Select Telegram Channel"

View file

@ -27,6 +27,17 @@ interface GroupedAlertNumberProps {
value: Alert['pk'];
}
const GroupedAlertNumber = observer(({ value }: GroupedAlertNumberProps) => {
const { alertGroupStore } = useStore();
const alert = alertGroupStore.items[value];
return (
<div>
#{alert?.inside_organization_number} {alert?.render_for_web?.title}
</div>
);
});
export const AttachIncidentForm = observer(({ id, onUpdate, onHide }: AttachIncidentFormProps) => {
const store = useStore();
@ -45,17 +56,6 @@ export const AttachIncidentForm = observer(({ id, onUpdate, onHide }: AttachInci
});
}, [selected, alertGroupStore, id, onHide, onUpdate]);
const GroupedAlertNumber = observer(({ value }: GroupedAlertNumberProps) => {
const { alertGroupStore } = useStore();
const alert = alertGroupStore.items[value];
return (
<div>
#{alert?.inside_organization_number} {alert?.render_for_web?.title}
</div>
);
});
return (
<Modal
isOpen
@ -74,12 +74,15 @@ export const AttachIncidentForm = observer(({ id, onUpdate, onHide }: AttachInci
description="Linking alert groups together can help the team investigate the underlying issue."
>
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<GSelect
<GSelect<Alert>
showSearch
modelName="alertGroupStore"
items={alertGroupStore.items}
fetchItemsFn={alertGroupStore.fetchItemsAvailableForAttachment}
fetchItemFn={alertGroupStore.updateItem}
getSearchResult={alertGroupStore.getSearchResult}
valueField="pk"
displayField="render_for_web.title"
placeholder="Select Incident"
placeholder="Select Alert Group"
className={cx('select', 'control')}
filterOptions={(optionId) => optionId !== id}
value={selected}

View file

@ -9,8 +9,9 @@ import { Block } from 'components/GBlock/Block';
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
import { Text } from 'components/Text/Text';
import { IncidentMatcher } from 'containers/IncidentMatcher/IncidentMatcher';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ChannelFilter, FilteringTermType } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils/utils';
@ -20,7 +21,7 @@ const cx = cn.bind(styles);
interface ChannelFilterFormProps {
id: ChannelFilter['id'] | 'new';
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
onHide: () => void;
onUpdate: (channelFilterId: ChannelFilter['id']) => void;
data?: ChannelFilter;
@ -63,7 +64,7 @@ export const ChannelFilterForm = observer((props: ChannelFilterFormProps) => {
const onUpdateClickCallback = useCallback(() => {
(id === 'new'
? alertReceiveChannelStore.createChannelFilter({
? AlertReceiveChannelHelper.createChannelFilter({
alert_receive_channel: alertReceiveChannelId,
filtering_term: filteringTerm,
filtering_term_type: filteringTermType,

View file

@ -9,8 +9,9 @@ import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesF
import { Block } from 'components/GBlock/Block';
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils/utils';
@ -21,7 +22,7 @@ const cx = cn.bind(styles);
interface EditRegexpRouteTemplateModalProps {
channelFilterId: ChannelFilter['id'];
template?: TemplateForEdit;
alertReceiveChannelId?: AlertReceiveChannel['id'];
alertReceiveChannelId?: ApiSchemas['AlertReceiveChannel']['id'];
onHide: () => void;
onUpdateRoute: (values: any, channelFilterId: ChannelFilter['id'], type: number) => void;
onOpenEditIntegrationTemplate?: (templateName: string, channelFilterId: ChannelFilter['id']) => void;
@ -59,14 +60,14 @@ export const EditRegexpRouteTemplateModal = observer((props: EditRegexpRouteTemp
}, [regexpTemplateBody]);
const handleConvertToJinja2 = useCallback(() => {
alertReceiveChannelStore.convertRegexpTemplateToJinja2Template(channelFilterId).then((response) => {
AlertReceiveChannelHelper.convertRegexpTemplateToJinja2Template(channelFilterId).then((response) => {
alertReceiveChannelStore
.saveChannelFilter(channelFilterId, {
filtering_term: response?.filtering_term_as_jinja2,
filtering_term_type: 1,
})
.then(() => {
alertReceiveChannelStore.updateChannelFilters(alertReceiveChannelId, true).then(() => {
alertReceiveChannelStore.fetchChannelFilters(alertReceiveChannelId, true).then(() => {
onOpenEditIntegrationTemplate('route_template', channelFilterId);
});
});

View file

@ -30,7 +30,7 @@ export const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
const { escalationChainId, onHide, onSubmit: onSubmitProp, mode } = props;
const store = useStore();
const { escalationChainStore, userStore } = store;
const { escalationChainStore, userStore, grafanaTeamStore } = store;
const user = userStore.currentUser;
@ -92,8 +92,10 @@ export const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
<Modal isOpen title={`${mode} Escalation Chain`} onDismiss={onHide}>
<div className={cx('root')}>
<Field label="Assign to team">
<GSelect
modelName="grafanaTeamStore"
<GSelect<GrafanaTeam>
items={grafanaTeamStore.items}
fetchItemsFn={grafanaTeamStore.updateItems}
getSearchResult={grafanaTeamStore.getSearchResult}
displayField="name"
valueField="id"
showSearch

View file

@ -6,23 +6,24 @@ import cn from 'classnames/bind';
import { get, isNil } from 'lodash-es';
import { observer } from 'mobx-react';
import { BaseStore } from 'models/base_store';
import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore';
import { useStore } from 'state/useStore';
import { useDebouncedCallback } from 'utils/hooks';
import { PropertiesThatExtendsAnotherClass } from 'utils/types';
import styles from './GSelect.module.scss';
const cx = cn.bind(styles);
interface GSelectProps {
interface GSelectProps<Item> {
items: {
[key: string]: Item;
};
fetchItemsFn: (query?: string) => Promise<Item[] | void>;
fetchItemFn?: (id: string) => Promise<Item | void>;
getSearchResult: (query?: string) => Item[] | { page_size: number; count: number; results: Item[] };
placeholder: string;
isLoading?: boolean;
value?: string | string[] | null;
defaultValue?: string | string[] | null;
onChange: (value: string, item: any) => void;
modelName: PropertiesThatExtendsAnotherClass<RootBaseStore, BaseStore>;
autoFocus?: boolean;
defaultOpen?: boolean;
disabled?: boolean;
@ -44,7 +45,7 @@ interface GSelectProps {
icon?: string;
}
export const GSelect = observer((props: GSelectProps) => {
export const GSelect = observer(<Item,>(props: GSelectProps<Item>) => {
const {
autoFocus,
showSearch = false,
@ -58,7 +59,6 @@ export const GSelect = observer((props: GSelectProps) => {
onChange,
disabled,
showError,
modelName,
displayField = 'display_name',
valueField = 'id',
isMulti = false,
@ -68,34 +68,37 @@ export const GSelect = observer((props: GSelectProps) => {
filterOptions,
width = null,
icon = null,
items: propItems,
fetchItemsFn,
fetchItemFn,
getSearchResult,
} = props;
const store = useStore();
const model = (store as any)[modelName];
const onChangeCallback = useCallback(
(option) => {
if (isMulti) {
const values = option.map((option: SelectableValue) => option.value);
const items = option.map((option: SelectableValue) => model.items[option.value]);
const items = option.map((option: SelectableValue) => propItems[option.value]);
onChange(values, items);
} else {
if (option) {
const id = option.value;
const item = model.items[id];
const item = propItems[id];
onChange(id, item);
} else {
onChange(null, null);
}
}
},
[model, onChange]
[propItems, onChange]
);
const loadOptions = useDebouncedCallback((query: string, cb) => {
model.updateItems(query).then(() => {
const searchResult = model.getSearchResult(query);
fetchItemsFn(query).then(() => {
const searchResult = getSearchResult(query);
// TODO: we need to unify interface of search results to get rid of ts-ignore
// @ts-ignore
let items = Array.isArray(searchResult.results) ? searchResult.results : searchResult;
if (filterOptions) {
items = items.filter((opt: any) => filterOptions(opt[valueField]));
@ -112,19 +115,17 @@ export const GSelect = observer((props: GSelectProps) => {
const values = isMulti
? (value ? (value as string[]) : [])
.filter((id) => id in model.items)
.filter((id) => id in propItems)
.map((id: string) => ({
value: id,
label: get(model.items[id], displayField),
description: getDescription && getDescription(model.items[id]),
label: get(propItems[id], displayField),
description: getDescription && getDescription(propItems[id]),
}))
: model.items[value as string]
: propItems[value as string]
? {
value,
label: get(model.items[value as string], displayField)
? get(model.items[value as string], displayField)
: 'hidden',
description: getDescription && getDescription(model.items[value as string]),
label: get(propItems[value as string], displayField) ? get(propItems[value as string], displayField) : 'hidden',
description: getDescription && getDescription(propItems[value as string]),
}
: value;
@ -132,8 +133,8 @@ export const GSelect = observer((props: GSelectProps) => {
const values = isMulti ? value : [value];
(values ? (values as string[]) : []).forEach((value: string) => {
if (!isNil(value) && !model.items[value] && model.updateItem) {
model.updateItem(value, true);
if (!isNil(value) && !propItems[value] && fetchItemFn) {
fetchItemFn(value);
}
});
}, [value]);

View file

@ -52,9 +52,11 @@ export const GrafanaTeamSelect = observer(
}
const select = (
<GSelect
<GSelect<GrafanaTeam>
showSearch
modelName="grafanaTeamStore"
items={grafanaTeamStore.items}
fetchItemsFn={grafanaTeamStore.updateItems}
getSearchResult={grafanaTeamStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select team"

View file

@ -9,7 +9,7 @@ import Emoji from 'react-emoji-render';
import { Text } from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartGreenIcon, HeartRedIcon } from 'icons/Icons';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -20,7 +20,7 @@ import styles from './HeartbeatForm.module.css';
const cx = cn.bind(styles);
interface HeartBeatModalProps {
alertReceveChannelId: AlertReceiveChannel['id'];
alertReceveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
onUpdate: () => void;
}

View file

@ -10,8 +10,8 @@ import { Text } from 'components/Text/Text';
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
import styles from 'containers/IntegrationContainers/CollapsedIntegrationRouteDisplay/CollapsedIntegrationRouteDisplay.module.scss';
import { RouteButtonsDisplay } from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { CommonIntegrationHelper } from 'pages/integration/CommonIntegration.helper';
import { IntegrationHelper } from 'pages/integration/Integration.helper';
import { useStore } from 'state/useStore';
@ -19,7 +19,7 @@ import { useStore } from 'state/useStore';
const cx = cn.bind(styles);
interface CollapsedIntegrationRouteDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
toggle: () => void;

View file

@ -33,10 +33,10 @@ import { EscalationChainSteps } from 'containers/EscalationChainSteps/Escalation
import styles from 'containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.module.scss';
import { TeamName } from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { CommonIntegrationHelper } from 'pages/integration/CommonIntegration.helper';
import { IntegrationHelper } from 'pages/integration/Integration.helper';
import { MONACO_INPUT_HEIGHT_SMALL } from 'pages/integration/IntegrationCommon.config';
@ -47,7 +47,7 @@ import { openNotification } from 'utils/utils';
const cx = cn.bind(styles);
interface ExpandedIntegrationRouteDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
templates: AlertTemplatesDTO[];
@ -372,7 +372,7 @@ const ReadOnlyEscalationChain: React.FC<{ escalationChainId: string }> = ({ esca
};
interface RouteButtonsDisplayProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
channelFilterId: ChannelFilter['id'];
routeIndex: number;
setRouteIdForDeletion(): void;

View file

@ -8,7 +8,7 @@ import { observer } from 'mobx-react';
import { IntegrationInputField } from 'components/IntegrationInputField/IntegrationInputField';
import { Text } from 'components/Text/Text';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { SelectOption } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -20,7 +20,7 @@ import styles from './IntegrationHeartbeatForm.module.scss';
const cx = cn.bind(styles);
interface IntegrationHeartbeatFormProps {
alertReceveChannelId: AlertReceiveChannel['id'];
alertReceveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
onClose?: () => void;
}
@ -117,7 +117,7 @@ const _IntegrationHeartbeatForm = observer(({ alertReceveChannelId, onClose }: I
openNotification('Heartbeat settings have been updated');
await alertReceiveChannelStore.loadItem(alertReceveChannelId);
await alertReceiveChannelStore.fetchItemById(alertReceveChannelId);
}
});

View file

@ -10,8 +10,8 @@ import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { Text } from 'components/Text/Text';
import { getTemplatesToRender } from 'containers/IntegrationContainers/IntegrationTemplatesList.config';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { IntegrationHelper } from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
import { MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config';
@ -22,7 +22,7 @@ const cx = cn.bind(styles);
interface IntegrationTemplateListProps {
templates: AlertTemplatesDTO[];
alertReceiveChannelId: AlertReceiveChannel['id'];
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
openEditTemplateModal: (templateName: string | string[]) => void;
alertReceiveChannelIsBasedOnAlertManager: boolean;
alertReceiveChannelAllowSourceBasedResolving: boolean;
@ -37,9 +37,9 @@ export const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = o
alertReceiveChannelAllowSourceBasedResolving,
}) => {
const { alertReceiveChannelStore, features } = useStore();
const [isRestoringTemplate, setIsRestoringTemplate] = useState<boolean>(false);
const [isRestoringTemplate, setIsRestoringTemplate] = useState(false);
const [templateRestoreName, setTemplateRestoreName] = useState<string>(undefined);
const [autoresolveValue, setAutoresolveValue] = useState<boolean>(alertReceiveChannelAllowSourceBasedResolving);
const [autoresolveValue, setAutoresolveValue] = useState(alertReceiveChannelAllowSourceBasedResolving);
const handleSaveClick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setAutoresolveValue(event.target.checked);

View file

@ -2,10 +2,11 @@ import React from 'react';
import { Icon, Label, Tooltip } from '@grafana/ui';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { FormItemType } from 'components/GForm/GForm.types';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { generateAssignToTeamInputDescription } from 'utils/consts';
export const form: { name: string; fields: FormItem[] } = {
export const getForm = (grafanaTeamStore: GrafanaTeamStore) => ({
name: 'Integration',
fields: [
{
@ -33,7 +34,9 @@ export const form: { name: string; fields: FormItem[] } = {
),
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
items: grafanaTeamStore.items,
fetchItemsFn: grafanaTeamStore.updateItems,
getSearchResult: grafanaTeamStore.getSearchResult,
displayField: 'name',
valueField: 'id',
showSearch: true,
@ -77,4 +80,4 @@ export const form: { name: string; fields: FormItem[] } = {
render: true,
},
],
};
});

View file

@ -1,6 +1,6 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
export function prepareForEdit(item: AlertReceiveChannel) {
export function prepareForEdit(item: ApiSchemas['AlertReceiveChannel']) {
return {
verbal_name: item.verbal_name,
description_short: item.description_short,

View file

@ -1,4 +1,4 @@
import React, { useState, ChangeEvent, useEffect, useReducer, useRef } from 'react';
import React, { useState, ChangeEvent, useEffect, useReducer, useRef, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import {
@ -26,10 +26,8 @@ import { PluginLink } from 'components/PluginLink/PluginLink';
import { Text } from 'components/Text/Text';
import { Labels } from 'containers/Labels/Labels';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import {
AlertReceiveChannel,
AlertReceiveChannelOption,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { IntegrationHelper } from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
@ -37,18 +35,18 @@ import { UserActions } from 'utils/authorization/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { openErrorNotification } from 'utils/utils';
import { form } from './IntegrationForm.config';
import { getForm } from './IntegrationForm.config';
import { prepareForEdit } from './IntegrationForm.helpers';
import styles from './IntegrationForm.module.scss';
const cx = cn.bind(styles);
interface IntegrationFormProps {
id: AlertReceiveChannel['id'] | 'new';
id: ApiSchemas['AlertReceiveChannel']['id'] | 'new';
isTableView?: boolean;
onHide: () => void;
onSubmit: () => Promise<void>;
navigateToAlertGroupLabels: (id: AlertReceiveChannel['id']) => void;
navigateToAlertGroupLabels: (id: ApiSchemas['AlertReceiveChannel']['id']) => void;
}
export const IntegrationForm = observer((props: IntegrationFormProps) => {
@ -61,18 +59,21 @@ export const IntegrationForm = observer((props: IntegrationFormProps) => {
const {
alertReceiveChannelStore,
userStore: { currentUser: user },
grafanaTeamStore,
} = store;
const [filterValue, setFilterValue] = useState('');
const [showNewIntegrationForm, setShowNewIntegrationForm] = useState(false);
const [selectedOption, setSelectedOption] = useState<AlertReceiveChannelOption>(undefined);
const [selectedOption, setSelectedOption] = useState<ApiSchemas['AlertReceiveChannelIntegrationOptions']>(undefined);
const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new');
const [allContactPoints, setAllContactPoints] = useState([]);
const [errors, setErrors] = useState<Record<string, any>>();
const form = useMemo(() => getForm(grafanaTeamStore), [grafanaTeamStore]);
useEffect(() => {
(async function () {
setAllContactPoints(await alertReceiveChannelStore.getGrafanaAlertingContactPoints());
setAllContactPoints(await AlertReceiveChannelHelper.getGrafanaAlertingContactPoints());
})();
}, []);
@ -84,7 +85,7 @@ export const IntegrationForm = observer((props: IntegrationFormProps) => {
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
const options = alertReceiveChannelOptions
? alertReceiveChannelOptions.filter((option: AlertReceiveChannelOption) => {
? alertReceiveChannelOptions.filter((option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) => {
if (option.value === 'grafana_alerting' && !window.grafanaBootData.settings.unifiedAlertingEnabled) {
return false;
}
@ -219,58 +220,36 @@ export const IntegrationForm = observer((props: IntegrationFormProps) => {
if (isCreate) {
await createNewIntegration();
} else {
await alertReceiveChannelStore.update(id, data, undefined, true);
await alertReceiveChannelStore.update({ id, data, skipErrorHandling: true });
}
} catch (error) {
setErrors(error.response.data);
openErrorNotification(
`There was an issue ${isCreate ? 'creating' : 'updating'} the integration. Please try again.`
);
setErrors(error);
return;
}
await onSubmit();
onHide();
function createNewIntegration(): Promise<void | AlertReceiveChannel> {
let promise = alertReceiveChannelStore.create<AlertReceiveChannel>(data, true);
async function createNewIntegration(): Promise<void | ApiSchemas['AlertReceiveChannel']> {
const response = await alertReceiveChannelStore.create({ data, skipErrorHandling: true });
const pushHistory = (id) => history.push(`${PLUGIN_ROOT}/integrations/${id}`);
promise
.then((response) => {
if (!response) {
return;
}
if (!IntegrationHelper.isSpecificIntegration(selectedOption.value, 'grafana_alerting')) {
return pushHistory(response.id);
}
return (
data.is_existing
? alertReceiveChannelStore.connectContactPoint(response.id, data.alert_manager, data.contact_point)
: alertReceiveChannelStore.createContactPoint(response.id, data.alert_manager, data.contact_point)
)
.catch(onCatch)
.finally(() => pushHistory(response.id));
})
.catch(onCatch);
return promise;
}
function onCatch(err: any) {
if (err.response?.data?.length > 0) {
openErrorNotification(err.response.data);
} else {
openErrorNotification('Something went wrong, please try again later.');
if (!response) {
return;
}
if (!IntegrationHelper.isSpecificIntegration(selectedOption.value, 'grafana_alerting')) {
pushHistory(response.id);
}
await (data.is_existing
? AlertReceiveChannelHelper.connectContactPoint
: AlertReceiveChannelHelper.createContactPoint)(response.id, data.alert_manager, data.contact_point);
pushHistory(response.id);
}
}
function onBlockClick(option: AlertReceiveChannelOption) {
function onBlockClick(option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) {
setSelectedOption(option);
setShowNewIntegrationForm(true);
setShowIntegrarionsListDrawer(false);
@ -338,11 +317,9 @@ const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = ({
}
);
const { alertReceiveChannelStore } = useStore();
useEffect(() => {
(async function () {
const response = await alertReceiveChannelStore.getGrafanaAlertingContactPoints();
const response = await AlertReceiveChannelHelper.getGrafanaAlertingContactPoints();
setState({
allContactPoints: response,
dataSources: response.map((res) => ({ label: res.name, value: res.uid })),
@ -445,7 +422,9 @@ const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = ({
}
};
const HowTheIntegrationWorks: React.FC<{ selectedOption: AlertReceiveChannelOption }> = ({ selectedOption }) => {
const HowTheIntegrationWorks: React.FC<{ selectedOption: ApiSchemas['AlertReceiveChannelIntegrationOptions'] }> = ({
selectedOption,
}) => {
if (!selectedOption) {
return null;
}
@ -487,8 +466,8 @@ const HowTheIntegrationWorks: React.FC<{ selectedOption: AlertReceiveChannelOpti
};
const IntegrationBlocks: React.FC<{
options: AlertReceiveChannelOption[];
onBlockClick: (option: AlertReceiveChannelOption) => void;
options: Array<ApiSchemas['AlertReceiveChannelIntegrationOptions']>;
onBlockClick: (option: ApiSchemas['AlertReceiveChannelIntegrationOptions']) => void;
}> = ({ options, onBlockClick }) => {
return (
<div className={cx('cards')} data-testid="create-integration-modal">

View file

@ -1,7 +1,10 @@
import { getIsTooManyLabelsWarningVisible } from './IntegrationLabelsForm.helpers';
describe('getIsTooManyLabelsWarningVisible()', () => {
const CUSTOM_LABEL = { key: { id: 'c', name: 'c' }, value: { id: 'c', name: 'c' } };
const CUSTOM_LABEL = {
key: { id: 'c', name: 'c', prescribed: false },
value: { id: 'c', name: 'c', prescribed: false },
};
it('should return false if limit is not exceeded', () => {
expect(

View file

@ -1,6 +1,8 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
const countNumberOfInheritedAndCustomLabels = (alert_group_labels: AlertReceiveChannel['alert_group_labels']) => {
const countNumberOfInheritedAndCustomLabels = (
alert_group_labels: ApiSchemas['AlertReceiveChannel']['alert_group_labels']
) => {
const inheritedCount = alert_group_labels.inheritable
? Object.keys(alert_group_labels.inheritable).filter((labelKey) => alert_group_labels.inheritable?.[labelKey])
.length
@ -10,11 +12,11 @@ const countNumberOfInheritedAndCustomLabels = (alert_group_labels: AlertReceiveC
};
export const getIsTooManyLabelsWarningVisible = (
alert_group_labels: AlertReceiveChannel['alert_group_labels'],
alert_group_labels: ApiSchemas['AlertReceiveChannel']['alert_group_labels'],
limit = 15
) => countNumberOfInheritedAndCustomLabels(alert_group_labels) > limit;
export const getIsAddBtnDisabled = ({ custom }: AlertReceiveChannel['alert_group_labels']) => {
export const getIsAddBtnDisabled = ({ custom }: ApiSchemas['AlertReceiveChannel']['alert_group_labels']) => {
const lastItem = custom.at(-1);
return lastItem && (lastItem?.key.id === undefined || lastItem?.value.id === undefined);
};

View file

@ -21,7 +21,6 @@ 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 { splitToGroups } from 'models/label/label.helpers';
import { LabelsErrors } from 'models/label/label.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
@ -39,10 +38,10 @@ const cx = cn.bind(styles);
const INPUT_WIDTH = 280;
interface IntegrationLabelsFormProps {
id: AlertReceiveChannel['id'];
id: ApiSchemas['AlertReceiveChannel']['id'];
onSubmit: () => void;
onHide: () => void;
onOpenIntegrationSettings: (id: AlertReceiveChannel['id']) => void;
onOpenIntegrationSettings: (id: ApiSchemas['AlertReceiveChannel']['id']) => void;
}
export const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
@ -63,7 +62,9 @@ export const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps
const handleSave = async () => {
try {
await alertReceiveChannelStore.saveAlertReceiveChannel(id, { alert_group_labels: alertGroupLabels });
await alertReceiveChannelStore.saveAlertReceiveChannel(id, {
alert_group_labels: alertGroupLabels,
});
onSubmit();
onHide();
} catch (err) {
@ -246,9 +247,9 @@ export const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps
});
interface CustomLabelsProps {
alertGroupLabels: AlertReceiveChannel['alert_group_labels'];
alertGroupLabels: ApiSchemas['AlertReceiveChannel']['alert_group_labels'];
customLabelsErrors: LabelsErrors;
onChange: (value: AlertReceiveChannel['alert_group_labels']) => void;
onChange: (value: ApiSchemas['AlertReceiveChannel']['alert_group_labels']) => void;
onShowTemplateEditor: (index: number) => void;
}
@ -263,8 +264,8 @@ const CustomLabels = (props: CustomLabelsProps) => {
custom: [
...alertGroupLabels.custom,
{
key: { id: undefined, name: undefined },
value: { id: undefined, name: undefined },
key: { id: undefined, name: undefined, prescribed: false },
value: { id: undefined, name: undefined, prescribed: false },
},
],
});
@ -275,8 +276,8 @@ const CustomLabels = (props: CustomLabelsProps) => {
custom: [
...alertGroupLabels.custom,
{
key: { id: undefined, name: undefined },
value: { id: null, name: undefined }, // id = null means it's a templated value
key: { id: undefined, name: undefined, prescribed: false },
value: { id: null, name: undefined, prescribed: false }, // id = null means it's a templated value
},
],
});

View file

@ -20,10 +20,10 @@ import { Text } from 'components/Text/Text';
import { TemplateResult } from 'containers/TemplateResult/TemplateResult';
import { TemplatesAlertGroupsList, TEMPLATE_PAGE } from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { IntegrationTemplateOptions, LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config';
import { useStore } from 'state/useStore';
import { LocationHelper } from 'utils/LocationHelper';
@ -34,7 +34,7 @@ import styles from './IntegrationTemplate.module.scss';
const cx = cn.bind(styles);
interface IntegrationTemplateProps {
id: AlertReceiveChannel['id'];
id: ApiSchemas['AlertReceiveChannel']['id'];
channelFilterId?: ChannelFilter['id'];
template: TemplateForEdit;
templateBody: string;
@ -49,7 +49,7 @@ export const IntegrationTemplate = observer((props: IntegrationTemplateProps) =>
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState<boolean>(false);
const [chatOpsPermalink, setChatOpsPermalink] = useState(undefined);
const [alertGroupPayload, setAlertGroupPayload] = useState<JSON>(undefined);
const [alertGroupPayload, setAlertGroupPayload] = useState<{ [key: string]: unknown }>(undefined);
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(templateBody);
const [resultError, setResultError] = useState<string>(undefined);
const [isRecentAlertGroupExisting, setIsRecentAlertGroupExisting] = useState<boolean>(false);

View file

@ -3,10 +3,12 @@ import React from 'react';
import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { FormItemType } from 'components/GForm/GForm.types';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
export const form: { name: string; fields: FormItem[] } = {
export const getForm = (alertReceiveChannelStore: AlertReceiveChannelStore) => ({
name: 'Maintenance',
fields: [
{
@ -15,11 +17,15 @@ export const form: { name: string; fields: FormItem[] } = {
type: FormItemType.GSelect,
validation: { required: true },
extra: {
modelName: 'alertReceiveChannelStore',
items: alertReceiveChannelStore.items,
fetchItemsFn: alertReceiveChannelStore.fetchItems,
fetchItemFn: alertReceiveChannelStore.fetchItemById,
getSearchResult: () => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore),
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
disabled: undefined,
},
},
{
@ -77,4 +83,4 @@ export const form: { name: string; fields: FormItem[] } = {
},
},
],
};
});

View file

@ -7,12 +7,13 @@ import { observer } from 'mobx-react';
import { GForm } from 'components/GForm/GForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
import { openNotification, showApiError } from 'utils/utils';
import { form } from './MaintenanceForm.config';
import { getForm } from './MaintenanceForm.config';
import styles from './MaintenanceForm.module.css';
@ -20,7 +21,7 @@ const cx = cn.bind(styles);
interface MaintenanceFormProps {
initialData: {
alert_receive_channel_id?: AlertReceiveChannel['id'];
alert_receive_channel_id?: ApiSchemas['AlertReceiveChannel']['id'];
disabled?: boolean;
};
onHide: () => void;
@ -29,20 +30,17 @@ interface MaintenanceFormProps {
export const MaintenanceForm = observer((props: MaintenanceFormProps) => {
const { onUpdate, onHide, initialData = {} } = props;
const { alertReceiveChannelStore } = useStore();
const form = useMemo(() => getForm(alertReceiveChannelStore), [alertReceiveChannelStore]);
const maintenanceForm = useMemo(() => (initialData.disabled ? cloneDeep(form) : form), [initialData]);
const store = useStore();
const { alertReceiveChannelStore } = store;
const handleSubmit = useCallback(async (data) => {
try {
await alertReceiveChannelStore.startMaintenanceMode(
await AlertReceiveChannelHelper.startMaintenanceMode(
initialData.alert_receive_channel_id,
data.mode,
data.duration
);
onHide();
onUpdate();
openNotification('Maintenance has been started');

View file

@ -4,6 +4,9 @@ import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import { generateAssignToTeamInputDescription } from 'utils/consts';
import { KeyValuePair } from 'utils/utils';
@ -21,10 +24,17 @@ export const WebhookTriggerType = {
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
};
export function createForm(
presets: OutgoingWebhookPreset[] = [],
hasLabelsFeature?: boolean
): {
export function createForm({
presets = [],
grafanaTeamStore,
alertReceiveChannelStore,
hasLabelsFeature,
}: {
presets: OutgoingWebhookPreset[];
grafanaTeamStore: GrafanaTeamStore;
alertReceiveChannelStore: AlertReceiveChannelStore;
hasLabelsFeature?: boolean;
}): {
name: string;
fields: FormItem[];
} {
@ -50,7 +60,9 @@ export function createForm(
)} This setting does not effect execution of the webhook.`,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
items: grafanaTeamStore.items,
fetchItemsFn: grafanaTeamStore.updateItems,
getSearchResult: grafanaTeamStore.getSearchResult,
displayField: 'name',
valueField: 'id',
showSearch: true,
@ -148,7 +160,10 @@ export function createForm(
data.trigger_type === WebhookTriggerType.EscalationStep.key,
extra: {
placeholder: 'Choose (Optional)',
modelName: 'alertReceiveChannelStore',
items: alertReceiveChannelStore.items,
fetchItemsFn: alertReceiveChannelStore.fetchItems,
fetchItemFn: alertReceiveChannelStore.fetchItemById,
getSearchResult: () => AlertReceiveChannelHelper.getSearchResult(alertReceiveChannelStore),
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,

View file

@ -93,10 +93,15 @@ export const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) =>
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
const [filterValue, setFilterValue] = useState('');
const { outgoingWebhookStore, hasFeature } = useStore();
const { outgoingWebhookStore, hasFeature, grafanaTeamStore, alertReceiveChannelStore } = useStore();
const isNew = action === WebhookFormActionType.NEW;
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
const form = createForm({
presets: outgoingWebhookStore.outgoingWebhookPresets,
grafanaTeamStore,
alertReceiveChannelStore,
hasLabelsFeature: hasFeature(AppFeature.Labels),
});
const handleSubmit = useCallback(
async (data: Partial<OutgoingWebhook>) => {
@ -386,75 +391,76 @@ interface WebhookTabsProps {
formElement: React.ReactElement;
}
const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
id,
action,
activeTab,
data,
onHide,
onUpdate,
onDelete,
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
const { outgoingWebhookStore, hasFeature } = useStore();
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
<ConfirmModal {...(confirmationModal as ConfirmModalProps)} onDismiss={() => setConfirmationModal(undefined)} />
)}
const WebhookTabsContent: React.FC<WebhookTabsProps> = observer(
({ id, action, activeTab, data, onHide, onUpdate, onDelete, formElement }) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
const { outgoingWebhookStore, hasFeature, grafanaTeamStore, alertReceiveChannelStore } = useStore();
const form = createForm({
presets: outgoingWebhookStore.outgoingWebhookPresets,
grafanaTeamStore,
alertReceiveChannelStore,
hasLabelsFeature: hasFeature(AppFeature.Labels),
});
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
<ConfirmModal
{...(confirmationModal as ConfirmModalProps)}
onDismiss={() => setConfirmationModal(undefined)}
/>
)}
{activeTab === WebhookTabs.Settings.key && (
<>
<div className={cx('content')}>
{formElement}
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button
form={form.name}
variant="destructive"
type="button"
disabled={data.is_legacy}
onClick={() => {
setConfirmationModal({
isOpen: true,
body: 'The action cannot be undone.',
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: onDelete,
title: `Are you sure you want to delete webhook?`,
} as ConfirmModalProps);
}}
>
Delete Webhook
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
{data.is_legacy ? (
{activeTab === WebhookTabs.Settings.key && (
<>
<div className={cx('content')}>
<Text type="secondary">Legacy migrated webhooks are not editable. Make a copy to make changes.</Text>
{formElement}
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button
form={form.name}
variant="destructive"
type="button"
disabled={data.is_legacy}
onClick={() => {
setConfirmationModal({
isOpen: true,
body: 'The action cannot be undone.',
confirmText: 'Delete',
dismissText: 'Cancel',
onConfirm: onDelete,
title: `Are you sure you want to delete webhook?`,
} as ConfirmModalProps);
}}
>
Delete Webhook
</Button>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{action === WebhookFormActionType.NEW ? 'Create' : 'Update'} Webhook
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
</div>
</div>
) : (
''
)}
</>
)}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhookStatus id={id} onUpdate={onUpdate} />}
</div>
);
};
{data.is_legacy ? (
<div className={cx('content')}>
<Text type="secondary">Legacy migrated webhooks are not editable. Make a copy to make changes.</Text>
</div>
) : (
''
)}
</>
)}
{activeTab === WebhookTabs.LastRun.key && <OutgoingWebhookStatus id={id} onUpdate={onUpdate} />}
</div>
);
}
);
const WebhookPresetBlocks: React.FC<{
presets: OutgoingWebhookPreset[];

View file

@ -1,88 +1,95 @@
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { PRIVATE_CHANNEL_NAME } from 'models/slack_channel/slack_channel.config';
import { RootStore } from 'state/rootStore';
import { generateAssignToTeamInputDescription } from 'utils/consts';
const assignToTeamDescription = generateAssignToTeamInputDescription('Schedules');
const commonFields: FormItem[] = [
{
name: 'slack_channel_id',
label: 'Slack channel',
type: FormItemType.GSelect,
extra: {
modelName: 'slackChannelStore',
displayField: 'display_name',
showSearch: true,
allowClear: true,
nullItemName: PRIVATE_CHANNEL_NAME,
const getCommonFields = ({ slackChannelStore, userGroupStore }: RootStore): FormItem[] =>
[
{
name: 'slack_channel_id',
label: 'Slack channel',
type: FormItemType.GSelect,
extra: {
items: slackChannelStore.items,
fetchItemsFn: slackChannelStore.updateItems,
fetchItemFn: slackChannelStore.updateItem,
getSearchResult: slackChannelStore.getSearchResult,
displayField: 'display_name',
showSearch: true,
allowClear: true,
nullItemName: PRIVATE_CHANNEL_NAME,
},
description:
'Calendar parsing errors and notifications about the new on-call shift will be published in this channel.',
},
description:
'Calendar parsing errors and notifications about the new on-call shift will be published in this channel.',
},
{
name: 'user_group',
label: 'Slack user group',
type: FormItemType.GSelect,
extra: {
modelName: 'userGroupStore',
displayField: 'handle',
showSearch: true,
allowClear: true,
{
name: 'user_group',
label: 'Slack user group',
type: FormItemType.GSelect,
extra: {
items: userGroupStore.items,
fetchItemsFn: userGroupStore.updateItems,
getSearchResult: userGroupStore.getSearchResult,
displayField: 'handle',
showSearch: true,
allowClear: true,
},
description:
'Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name.',
},
description:
'Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name.',
},
{
name: 'notify_oncall_shift_freq',
label: 'Notification frequency',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/notify_oncall_shift_freq_options/',
displayField: 'display_name',
openMenuOnFocus: false,
{
name: 'notify_oncall_shift_freq',
label: 'Notification frequency',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/notify_oncall_shift_freq_options/',
displayField: 'display_name',
openMenuOnFocus: false,
},
description: 'Specify the frequency that shift notifications are sent to scheduled team members.',
},
description: 'Specify the frequency that shift notifications are sent to scheduled team members.',
},
{
name: 'notify_empty_oncall',
label: 'Action for slot when no one is on-call',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/notify_empty_oncall_options/',
displayField: 'display_name',
openMenuOnFocus: false,
{
name: 'notify_empty_oncall',
label: 'Action for slot when no one is on-call',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/notify_empty_oncall_options/',
displayField: 'display_name',
openMenuOnFocus: false,
},
description: 'Specify how to notify team members when there is no one scheduled for an on-call shift.',
},
description: 'Specify how to notify team members when there is no one scheduled for an on-call shift.',
},
{
name: 'mention_oncall_start',
label: 'Current shift notification settings',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/mention_options/',
displayField: 'display_name',
openMenuOnFocus: false,
{
name: 'mention_oncall_start',
label: 'Current shift notification settings',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/mention_options/',
displayField: 'display_name',
openMenuOnFocus: false,
},
description: 'Specify how to notify a team member when their on-call shift begins ',
},
description: 'Specify how to notify a team member when their on-call shift begins ',
},
{
name: 'mention_oncall_next',
label: 'Next shift notification settings',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/mention_options/',
displayField: 'display_name',
openMenuOnFocus: false,
{
name: 'mention_oncall_next',
label: 'Next shift notification settings',
type: FormItemType.RemoteSelect,
normalize: (value) => value,
extra: {
href: '/schedules/mention_options/',
displayField: 'display_name',
openMenuOnFocus: false,
},
description: 'Specify how to notify a team member when their shift is the next one scheduled',
},
description: 'Specify how to notify a team member when their shift is the next one scheduled',
},
].map((field) => ({ ...field, collapsed: true }));
].map((field) => ({ ...field, collapsed: true }));
export const iCalForm: { name: string; fields: FormItem[] } = {
export const getICalForm = (rootStore: RootStore) => ({
name: 'Schedule',
fields: [
{
@ -96,7 +103,9 @@ export const iCalForm: { name: string; fields: FormItem[] } = {
description: assignToTeamDescription,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
items: rootStore.grafanaTeamStore.items,
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
displayField: 'name',
valueField: 'id',
showSearch: true,
@ -117,11 +126,11 @@ export const iCalForm: { name: string; fields: FormItem[] } = {
extra: { rows: 2 },
},
...commonFields,
...getCommonFields(rootStore),
],
};
});
export const calendarForm: { name: string; fields: FormItem[] } = {
export const getCalendarForm = (rootStore: RootStore) => ({
name: 'Schedule',
fields: [
{
@ -135,7 +144,9 @@ export const calendarForm: { name: string; fields: FormItem[] } = {
description: assignToTeamDescription,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
items: rootStore.grafanaTeamStore.items,
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
displayField: 'name',
valueField: 'id',
showSearch: true,
@ -157,11 +168,11 @@ export const calendarForm: { name: string; fields: FormItem[] } = {
extra: { rows: 2 },
},
...commonFields,
...getCommonFields(rootStore),
],
};
});
export const apiForm: { name: string; fields: FormItem[] } = {
export const getApiForm = (rootStore: RootStore) => ({
name: 'Schedule',
fields: [
{
@ -175,13 +186,15 @@ export const apiForm: { name: string; fields: FormItem[] } = {
description: assignToTeamDescription,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
items: rootStore.grafanaTeamStore.items,
fetchItemsFn: rootStore.grafanaTeamStore.updateItems,
getSearchResult: rootStore.grafanaTeamStore.getSearchResult,
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
},
},
...commonFields,
...getCommonFields(rootStore),
],
};
});

View file

@ -11,7 +11,7 @@ import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
import { openWarningNotification } from 'utils/utils';
import { apiForm, calendarForm, iCalForm } from './ScheduleForm.config';
import { getApiForm, getCalendarForm, getICalForm } from './ScheduleForm.config';
import { prepareForEdit } from './ScheduleForm.helpers';
import styles from './ScheduleForm.module.css';
@ -25,12 +25,6 @@ interface ScheduleFormProps {
type?: ScheduleType;
}
const scheduleTypeToForm = {
[ScheduleType.Calendar]: calendarForm,
[ScheduleType.Ical]: iCalForm,
[ScheduleType.API]: apiForm,
};
export const ScheduleForm = observer((props: ScheduleFormProps) => {
const { id, type, onSubmit, onHide } = props;
const isNew = id === 'new';
@ -39,6 +33,15 @@ export const ScheduleForm = observer((props: ScheduleFormProps) => {
const { scheduleStore, userStore } = store;
const scheduleTypeToForm = useMemo(
() => ({
[ScheduleType.Calendar]: getCalendarForm(store),
[ScheduleType.Ical]: getICalForm(store),
[ScheduleType.API]: getApiForm(store),
}),
[]
);
const data = useMemo(() => {
return isNew ? { team: userStore.currentUser?.current_team, type } : prepareForEdit(scheduleStore.items[id]);
}, [id]);

View file

@ -5,9 +5,10 @@ import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { Text } from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config';
import { useStore } from 'state/useStore';
import { useDebouncedCallback } from 'utils/hooks';
@ -23,8 +24,8 @@ interface TemplatePreviewProps {
templateBody: string | null;
templateType?: 'plain' | 'html' | 'image' | 'boolean';
templateIsRoute?: boolean;
payload?: JSON;
alertReceiveChannelId: AlertReceiveChannel['id'];
payload?: { [key: string]: unknown };
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'];
alertGroupId?: Alert['pk'];
outgoingWebhookId?: OutgoingWebhook['id'];
templatePage: TEMPLATE_PAGE;
@ -58,14 +59,14 @@ export const TemplatePreview = observer((props: TemplatePreviewProps) => {
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
const store = useStore();
const { alertReceiveChannelStore, alertGroupStore, outgoingWebhookStore } = store;
const { alertGroupStore, outgoingWebhookStore } = store;
const handleTemplateBodyChange = useDebouncedCallback(() => {
(templatePage === TEMPLATE_PAGE.Webhooks
? outgoingWebhookStore.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
: alertGroupId
? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody)
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)
: AlertReceiveChannelHelper.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)
)
.then((data) => {
setResult(data);

View file

@ -8,19 +8,19 @@ import { Block } from 'components/GBlock/Block';
import { Text } from 'components/Text/Text';
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
import { TemplatePreview, TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
const cx = cn.bind(styles);
interface ResultProps {
alertReceiveChannelId?: AlertReceiveChannel['id'];
alertReceiveChannelId?: ApiSchemas['AlertReceiveChannel']['id'];
outgoingWebhookId?: OutgoingWebhook['id'];
templateBody: string;
template: TemplateForEdit;
isAlertGroupExisting?: boolean;
chatOpsPermalink?: string;
payload?: JSON;
payload?: { [key: string]: unknown };
error?: string;
onSaveAndFollowLink?: (link: string) => void;
templateIsRoute?: boolean;

View file

@ -8,10 +8,10 @@ import { MonacoEditor, MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEdi
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { Text } from 'components/Text/Text';
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { OutgoingWebhook, OutgoingWebhookResponse } from 'models/outgoing_webhook/outgoing_webhook.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import styles from './TemplatesAlertGroupsList.module.css';
@ -26,7 +26,7 @@ export enum TEMPLATE_PAGE {
interface TemplatesAlertGroupsListProps {
templatePage: TEMPLATE_PAGE;
templates: AlertTemplatesDTO[];
alertReceiveChannelId?: AlertReceiveChannel['id'];
alertReceiveChannelId?: ApiSchemas['AlertReceiveChannel']['id'];
outgoingwebhookId?: OutgoingWebhook['id'];
heading?: string;

View file

@ -1,11 +1,186 @@
import { AlertReceiveChannel } from './alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { onCallApi } from 'network/oncall-api/http-client';
import { SelectOption } from 'state/types';
import { showApiError } from 'utils/utils';
export function getAlertReceiveChannelDisplayName(alertReceiveChannel?: AlertReceiveChannel, withDescription = true) {
if (!alertReceiveChannel) {
return '';
import { AlertReceiveChannelStore } from './alert_receive_channel';
import { MaintenanceMode } from './alert_receive_channel.types';
export class AlertReceiveChannelHelper {
static getAlertReceiveChannelDisplayName(
alertReceiveChannel?: ApiSchemas['AlertReceiveChannel'],
withDescription = true
) {
if (!alertReceiveChannel) {
return '';
}
return withDescription && alertReceiveChannel.description
? `${alertReceiveChannel.verbal_name} (${alertReceiveChannel.description})`
: alertReceiveChannel.verbal_name;
}
return withDescription && alertReceiveChannel.description
? `${alertReceiveChannel.verbal_name} (${alertReceiveChannel.description})`
: alertReceiveChannel.verbal_name;
static getSearchResult(store: AlertReceiveChannelStore) {
return store.searchResult
? store.searchResult.map(
(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id']) => store.items?.[alertReceiveChannelId]
)
: undefined;
}
static getPaginatedSearchResult(store: AlertReceiveChannelStore) {
return store.paginatedSearchResult
? {
page_size: store.paginatedSearchResult.page_size,
count: store.paginatedSearchResult.count,
results: store.paginatedSearchResult.results?.map(
(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id']) => store.items?.[alertReceiveChannelId]
),
}
: undefined;
}
static getIntegration(
store: AlertReceiveChannelStore,
alertReceiveChannel: Partial<ApiSchemas['AlertReceiveChannel'] | ApiSchemas['FastAlertReceiveChannel']>
): SelectOption {
return (
store.alertReceiveChannelOptions &&
alertReceiveChannel &&
store.alertReceiveChannelOptions.find(
(alertReceiveChannelOption: SelectOption) => alertReceiveChannelOption.value === alertReceiveChannel.integration
)
);
}
static async deleteAlertReceiveChannel(id: ApiSchemas['AlertReceiveChannel']['id']) {
return (await onCallApi().DELETE('/alert_receive_channels/{id}/', { params: { path: { id } } })).data;
}
static async getGrafanaAlertingContactPoints() {
return (await onCallApi().GET('/alert_receive_channels/contact_points/', undefined)).data;
}
static async connectContactPoint(
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'],
datasource_uid: string,
contact_point_name: string
) {
return (
await onCallApi().POST('/alert_receive_channels/{id}/connect_contact_point/', {
params: { path: { id: alertReceiveChannelId } },
body: {
datasource_uid,
contact_point_name,
},
})
).data;
}
static async disconnectContactPoint(
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'],
datasource_uid: string,
contact_point_name: string
) {
return (
await onCallApi().POST('/alert_receive_channels/{id}/disconnect_contact_point/', {
params: { path: { id: alertReceiveChannelId } },
body: {
datasource_uid,
contact_point_name,
},
})
).data;
}
static async createContactPoint(
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'],
datasource_uid: string,
contact_point_name: string
) {
return (
await onCallApi().POST('/alert_receive_channels/{id}/create_contact_point/', {
params: { path: { id: alertReceiveChannelId } },
body: {
datasource_uid,
contact_point_name,
},
})
).data;
}
static async sendDemoAlert(id: ApiSchemas['AlertReceiveChannel']['id'], payload?: { [key: string]: unknown }) {
await onCallApi().POST('/alert_receive_channels/{id}/send_demo_alert/', {
params: { path: { id } },
body: { demo_alert_payload: payload },
});
}
static async renderPreview(
id: ApiSchemas['AlertReceiveChannel']['id'],
template_name: string,
template_body: string,
payload: { [key: string]: unknown }
) {
return (
await onCallApi().POST('/alertgroups/{id}/preview_template/', {
params: { path: { id } },
body: { template_name, template_body, payload },
})
).data;
}
static async changeTeam(id: ApiSchemas['AlertReceiveChannel']['id'], teamId: GrafanaTeam['id']) {
return (
await onCallApi().PUT('/alert_receive_channels/{id}/change_team/', {
params: { path: { id }, query: { team_id: String(teamId) } },
})
).data;
}
static async migrateChannel(id: ApiSchemas['AlertReceiveChannel']['id']) {
return (await onCallApi().POST('/alert_receive_channels/{id}/migrate/', { params: { path: { id } } })).data;
}
static async startMaintenanceMode(
id: ApiSchemas['AlertReceiveChannel']['id'],
mode: MaintenanceMode,
duration: ApiSchemas['DurationEnum']
) {
return (
await onCallApi().POST('/alert_receive_channels/{id}/start_maintenance/', {
params: { path: { id } },
body: {
mode,
duration,
},
})
).data;
}
static async stopMaintenanceMode(id: ApiSchemas['AlertReceiveChannel']['id']) {
return (await onCallApi().POST('/alert_receive_channels/{id}/stop_maintenance/', { params: { path: { id } } }))
.data;
}
static async sendDemoAlertToParticularRoute(id: ChannelFilter['id']) {
await makeRequest(`/channel_filters/${id}/send_demo_alert/`, { method: 'POST' }).catch(showApiError);
}
static async convertRegexpTemplateToJinja2Template(id: ChannelFilter['id']) {
const result = await makeRequest(`/channel_filters/${id}/convert_from_regex_to_jinja2/`, { method: 'POST' }).catch(
showApiError
);
return result;
}
static async createChannelFilter(data: Partial<ChannelFilter>) {
return await makeRequest('/channel_filters/', {
method: 'POST',
data,
});
}
}

View file

@ -1,125 +1,110 @@
import { omit } from 'lodash-es';
import { action, observable, makeObservable, runInAction } from 'mobx';
import { runInAction, makeAutoObservable } from 'mobx';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { Alert } from 'models/alertgroup/alertgroup.types';
import { BaseStore } from 'models/base_store';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { operations } from 'network/oncall-api/autogenerated-api.types';
import { onCallApi } from 'network/oncall-api/http-client';
import { move } from 'state/helpers';
import { RootStore } from 'state/rootStore';
import { SelectOption } from 'state/types';
import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore';
import { WithGlobalNotification } from 'utils/decorators';
import { showApiError } from 'utils/utils';
import { OmitReadonlyMembers } from 'utils/types';
import {
AlertReceiveChannel,
AlertReceiveChannelOption,
AlertReceiveChannelCounters,
ContactPoint,
MaintenanceMode,
SupportedIntegrationFilters,
} from './alert_receive_channel.types';
import { AlertReceiveChannelCounters, ContactPoint } from './alert_receive_channel.types';
export class AlertReceiveChannelStore extends BaseStore {
@observable.shallow
searchResult: Array<AlertReceiveChannel['id']>;
@observable.shallow
paginatedSearchResult: { count?: number; results?: Array<AlertReceiveChannel['id']>; page_size?: number } = {};
@observable.shallow
items: { [id: string]: AlertReceiveChannel } = {};
@observable.shallow
export class AlertReceiveChannelStore {
path = '/alert_receive_channels/';
rootStore: RootBaseStore;
searchResult: Array<ApiSchemas['AlertReceiveChannel']['id']>;
paginatedSearchResult: {
count?: number;
results?: Array<ApiSchemas['AlertReceiveChannel']['id']>;
page_size?: number;
} = {};
items: {
[id: string]: ApiSchemas['AlertReceiveChannel'];
} = {};
counters: { [id: string]: AlertReceiveChannelCounters } = {};
@observable
channelFilterIds: { [id: string]: Array<ChannelFilter['id']> } = {};
@observable.shallow
channelFilters: { [id: string]: ChannelFilter } = {};
@observable
alertReceiveChannelToHeartbeat: {
[id: string]: Heartbeat['id'];
} = {};
@observable.shallow
actions: { [id: string]: OutgoingWebhook[] } = {};
@observable.shallow
alertReceiveChannelOptions: AlertReceiveChannelOption[] = [];
@observable.shallow
alertReceiveChannelOptions: Array<ApiSchemas['AlertReceiveChannelIntegrationOptions']> = [];
templates: { [id: string]: AlertTemplatesDTO[] } = {};
@observable
connectedContactPoints: { [id: string]: ContactPoint[] } = {};
constructor(rootStore: RootStore) {
super(rootStore);
makeObservable(this);
this.path = '/alert_receive_channels/';
constructor(rootStore: RootBaseStore) {
makeAutoObservable(this, undefined, { autoBind: true });
this.rootStore = rootStore;
}
getSearchResult(_query = '') {
if (!this.searchResult) {
return undefined;
}
return this.searchResult.map(
(alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
);
@WithGlobalNotification({ failure: 'There was an issue creating Integration. Please try again.' })
async create({ data, skipErrorHandling }: { data: ApiSchemas['AlertReceiveChannel']; skipErrorHandling?: boolean }) {
const result = await onCallApi({ skipErrorHandling }).POST('/alert_receive_channels/', {
params: {},
body: data,
});
await this.rootStore.organizationStore.loadCurrentOrganization();
return result.data;
}
getPaginatedSearchResult(_query = '') {
if (!this.paginatedSearchResult) {
return undefined;
}
return {
page_size: this.paginatedSearchResult.page_size,
count: this.paginatedSearchResult.count,
results: this.paginatedSearchResult.results?.map(
(alertReceiveChannelId: AlertReceiveChannel['id']) => this.items?.[alertReceiveChannelId]
),
};
@WithGlobalNotification({ failure: 'There was an issue updating Integration. Please try again.' })
async update({
id,
data,
skipErrorHandling,
}: {
id: ApiSchemas['AlertReceiveChannelUpdate']['id'];
data: ApiSchemas['AlertReceiveChannelUpdate'];
skipErrorHandling?: boolean;
}) {
const result = await onCallApi({ skipErrorHandling }).PUT('/alert_receive_channels/{id}/', {
params: { path: { id } },
body: data,
});
await this.rootStore.organizationStore.loadCurrentOrganization();
return result.data;
}
@action
async loadItem(id: AlertReceiveChannel['id'], skipErrorHandling = false): Promise<AlertReceiveChannel> {
const alertReceiveChannel = await this.getById(id, skipErrorHandling);
async fetchItemById(
id: ApiSchemas['AlertReceiveChannel']['id'],
skipErrorHandling = false
): Promise<ApiSchemas['AlertReceiveChannel']> {
const alertReceiveChannel = await onCallApi({ skipErrorHandling }).GET('/alert_receive_channels/{id}/', {
params: { path: { id } },
});
runInAction(() => {
// @ts-ignore
this.items = {
...this.items,
[id]: omit(alertReceiveChannel, 'heartbeat'),
[id]: { ...alertReceiveChannel.data, heartbeat: alertReceiveChannel.data.heartbeat || null },
};
});
this.populateHearbeats([alertReceiveChannel]);
this.populateHearbeats([alertReceiveChannel.data]);
return alertReceiveChannel;
return alertReceiveChannel.data;
}
@action
async updateItems(query: any = '') {
async fetchItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
const { results } = await makeRequest(this.path, { params });
const {
data: { results },
} = await onCallApi().GET('/alert_receive_channels/', { params });
runInAction(() => {
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: AlertReceiveChannel }, item: AlertReceiveChannel) => ({
(acc: { [key: number]: ApiSchemas['AlertReceiveChannel'] }, item: ApiSchemas['AlertReceiveChannel']) => ({
...acc,
[item.id]: omit(item, 'heartbeat'),
}),
@ -131,26 +116,28 @@ export class AlertReceiveChannelStore extends BaseStore {
this.populateHearbeats(results);
runInAction(() => {
this.searchResult = results.map((item: AlertReceiveChannel) => item.id);
this.searchResult = results.map((item: ApiSchemas['AlertReceiveChannel']) => item.id);
});
this.updateCounters();
this.fetchCounters();
return results;
}
async updatePaginatedItems({
async fetchPaginatedItems({
filters,
page = 1,
updateCounters = false,
shouldFetchCounters = false,
invalidateFn = undefined,
}: {
filters: SupportedIntegrationFilters;
filters: operations['alert_receive_channels_list']['parameters']['query'];
page: number;
updateCounters: boolean;
shouldFetchCounters: boolean;
invalidateFn: () => boolean;
}) {
const { count, results, page_size } = await makeRequest(this.path, { params: { ...filters, page } });
const {
data: { count, results, page_size },
} = await onCallApi().GET('/alert_receive_channels/', { params: { query: { ...filters, page } } });
if (invalidateFn?.()) {
return undefined;
@ -160,7 +147,7 @@ export class AlertReceiveChannelStore extends BaseStore {
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: AlertReceiveChannel }, item: AlertReceiveChannel) => ({
(acc: { [key: number]: ApiSchemas['AlertReceiveChannel'] }, item: ApiSchemas['AlertReceiveChannel']) => ({
...acc,
[item.id]: omit(item, 'heartbeat'),
}),
@ -174,26 +161,29 @@ export class AlertReceiveChannelStore extends BaseStore {
runInAction(() => {
this.paginatedSearchResult = {
count,
results: results.map((item: AlertReceiveChannel) => item.id),
results: results.map((item: ApiSchemas['AlertReceiveChannel']) => item.id),
page_size,
};
});
if (updateCounters) {
this.updateCounters();
if (shouldFetchCounters) {
this.fetchCounters();
}
return results;
}
populateHearbeats(alertReceiveChannels: AlertReceiveChannel[]) {
const heartbeats = alertReceiveChannels.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
}
populateHearbeats(alertReceiveChannels: Array<ApiSchemas['AlertReceiveChannelPolymorphic']>) {
const heartbeats = alertReceiveChannels.reduce(
(acc: any, alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat;
}
return acc;
}, {});
return acc;
},
{}
);
runInAction(() => {
this.rootStore.heartbeatStore.items = {
@ -203,7 +193,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
const alertReceiveChannelToHeartbeat = alertReceiveChannels.reduce(
(acc: any, alertReceiveChannel: AlertReceiveChannel) => {
(acc: any, alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) => {
if (alertReceiveChannel.heartbeat) {
acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id;
}
@ -221,8 +211,7 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action
async updateChannelFilters(alertReceiveChannelId: AlertReceiveChannel['id'], isOverwrite = false) {
async fetchChannelFilters(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'], isOverwrite = false) {
const response = await makeRequest(`/channel_filters/`, {
params: { alert_receive_channel: alertReceiveChannelId },
});
@ -259,32 +248,6 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action
async updateChannelFilter(channelFilterId: ChannelFilter['id']) {
const response = await makeRequest(`/channel_filters/${channelFilterId}/`, {});
runInAction(() => {
this.channelFilters = {
...this.channelFilters,
[channelFilterId]: response,
};
});
}
async migrateChannel(id: AlertReceiveChannel['id']) {
return await makeRequest(`/alert_receive_channels/${id}/migrate`, {
method: 'POST',
});
}
async createChannelFilter(data: Partial<ChannelFilter>) {
return await makeRequest('/channel_filters/', {
method: 'POST',
data,
});
}
@action
async saveChannelFilter(channelFilterId: ChannelFilter['id'], data: Partial<ChannelFilter>) {
const response = await makeRequest(`/channel_filters/${channelFilterId}/`, {
method: 'PUT',
@ -301,9 +264,8 @@ export class AlertReceiveChannelStore extends BaseStore {
return response;
}
@action
async moveChannelFilterToPosition(
alertReceiveChannelId: AlertReceiveChannel['id'],
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'],
oldIndex: number,
newIndex: number
) {
@ -317,10 +279,9 @@ export class AlertReceiveChannelStore extends BaseStore {
await makeRequest(`/channel_filters/${channelFilterId}/move_to_position/?position=${newIndex}`, { method: 'PUT' });
this.updateChannelFilters(alertReceiveChannelId, true);
this.fetchChannelFilters(alertReceiveChannelId, true);
}
@action
async deleteChannelFilter(channelFilterId: ChannelFilter['id']) {
const channelFilter = this.channelFilters[channelFilterId];
@ -333,47 +294,43 @@ export class AlertReceiveChannelStore extends BaseStore {
method: 'DELETE',
});
return this.updateChannelFilters(channelFilter.alert_receive_channel, true);
return this.fetchChannelFilters(channelFilter.alert_receive_channel, true);
}
@action.bound
async updateAlertReceiveChannelOptions() {
const response = await makeRequest(`/alert_receive_channels/integration_options/`, {});
async fetchAlertReceiveChannelOptions() {
const { data } = await onCallApi().GET(`/alert_receive_channels/integration_options/`, undefined);
runInAction(() => {
this.alertReceiveChannelOptions = response;
this.alertReceiveChannelOptions = data;
});
}
getIntegration(alertReceiveChannel: Partial<AlertReceiveChannel>): SelectOption {
return (
this.alertReceiveChannelOptions &&
alertReceiveChannel &&
this.alertReceiveChannelOptions.find(
(alertReceiveChannelOption: SelectOption) => alertReceiveChannelOption.value === alertReceiveChannel.integration
)
);
}
@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, undefined, true);
async saveAlertReceiveChannel(
id: ApiSchemas['AlertReceiveChannel']['id'],
payload: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelUpdate']>
) {
const currentIntegration = this.items[id];
const { data } = await onCallApi().PUT('/alert_receive_channels/{id}/', {
params: { path: { id } },
body: {
description_short: currentIntegration.description_short,
verbal_name: currentIntegration.verbal_name,
allow_source_based_resolving: currentIntegration.allow_source_based_resolving,
alert_group_labels: currentIntegration.alert_group_labels,
...payload,
} as ApiSchemas['AlertReceiveChannelUpdate'],
});
runInAction(() => {
this.items = {
...this.items,
[id]: item,
[id]: data,
};
});
}
async deleteAlertReceiveChannel(id: AlertReceiveChannel['id']) {
return await this.delete(id);
}
@action
async updateTemplates(alertReceiveChannelId: AlertReceiveChannel['id'], alertGroupId?: Alert['pk']) {
async fetchTemplates(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'], alertGroupId?: Alert['pk']) {
const response = await makeRequest(`/alert_receive_channel_templates/${alertReceiveChannelId}/`, {
params: { alert_group_id: alertGroupId },
withCredentials: true,
@ -387,20 +344,10 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
@action
async updateItem(id: AlertReceiveChannel['id']) {
const item = await this.getById(id);
runInAction(() => {
this.items = {
...this.items,
[id]: item,
};
});
}
@action
async saveTemplates(alertReceiveChannelId: AlertReceiveChannel['id'], data: Partial<AlertTemplatesDTO>) {
async saveTemplates(
alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'],
data: Partial<AlertTemplatesDTO>
) {
const response = await makeRequest(`/alert_receive_channel_templates/${alertReceiveChannelId}/`, {
method: 'PUT',
data,
@ -415,26 +362,23 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
async getGrafanaAlertingContactPoints() {
return await makeRequest(`${this.path}contact_points/`, {}).catch(showApiError);
}
@action
async updateConnectedContactPoints(alertReceiveChannelId: AlertReceiveChannel['id']) {
const response = await makeRequest(`${this.path}${alertReceiveChannelId}/connected_contact_points `, {});
async fetchConnectedContactPoints(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id']) {
const { data } = await onCallApi().GET('/alert_receive_channels/{id}/connected_contact_points/', {
params: { path: { id: alertReceiveChannelId } },
});
runInAction(() => {
this.connectedContactPoints = {
...this.connectedContactPoints,
[alertReceiveChannelId]: response.reduce((list: ContactPoint[], payload) => {
payload.contact_points.forEach((contactPoint: { name: string; notification_connected: boolean }) => {
[alertReceiveChannelId]: data.reduce((list: ContactPoint[], payload) => {
payload.contact_points.forEach((contactPoint) => {
list.push({
dataSourceName: payload.name,
dataSourceId: payload.uid,
contactPoint: contactPoint.name,
notificationConnected: contactPoint.notification_connected,
} as ContactPoint);
});
});
return list;
@ -443,140 +387,25 @@ export class AlertReceiveChannelStore extends BaseStore {
});
}
async connectContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
contact_point_name: string
) {
return await makeRequest(`${this.path}${alertReceiveChannelId}/connect_contact_point`, {
method: 'POST',
data: {
datasource_uid,
contact_point_name,
},
});
}
async disconnectContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
contact_point_name: string
) {
return await makeRequest(`${this.path}${alertReceiveChannelId}/disconnect_contact_point`, {
method: 'POST',
data: {
datasource_uid,
contact_point_name,
},
});
}
async createContactPoint(
alertReceiveChannelId: AlertReceiveChannel['id'],
datasource_uid: string,
contact_point_name: string
) {
return await makeRequest(`${this.path}${alertReceiveChannelId}/create_contact_point`, {
method: 'POST',
data: {
datasource_uid,
contact_point_name,
},
});
}
async getAccessLogs(alertReceiveChannelId: AlertReceiveChannel['id']) {
const { integration_log } = await makeRequest(`/alert_receive_channel_access_log/${alertReceiveChannelId}/`, {});
return integration_log;
}
async sendDemoAlert(id: AlertReceiveChannel['id'], payload: string = undefined) {
const requestConfig: any = {
method: 'POST',
};
if (payload) {
requestConfig.data = {
demo_alert_payload: payload,
};
}
await makeRequest(`${this.path}${id}/send_demo_alert/`, requestConfig).catch(showApiError);
}
async sendDemoAlertToParticularRoute(id: ChannelFilter['id']) {
await makeRequest(`/channel_filters/${id}/send_demo_alert/`, { method: 'POST' }).catch(showApiError);
}
async convertRegexpTemplateToJinja2Template(id: ChannelFilter['id']) {
const result = await makeRequest(`/channel_filters/${id}/convert_from_regex_to_jinja2/`, { method: 'POST' }).catch(
showApiError
);
return result;
}
async renderPreview(id: AlertReceiveChannel['id'], template_name: string, template_body: string, payload: JSON) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body, payload },
});
}
async changeTeam(id: AlertReceiveChannel['id'], teamId: GrafanaTeam['id']) {
return await makeRequest(`${this.path}${id}/change_team`, {
params: { team_id: String(teamId) },
method: 'PUT',
});
}
@action
async updateCounters() {
const counters = await makeRequest(`${this.path}counters`, {
method: 'GET',
});
async fetchCounters() {
const { data } = await onCallApi().GET('/alert_receive_channels/counters/', undefined);
runInAction(() => {
this.counters = counters;
this.counters = data;
});
}
@action
async updateCountersForIntegration(id: AlertReceiveChannel['id']): Promise<any> {
const counters = await makeRequest(`${this.path}${id}/counters`, {
method: 'GET',
});
async fetchCountersForIntegration(id: ApiSchemas['AlertReceiveChannel']['id']) {
const { data } = await onCallApi().GET('/alert_receive_channels/{id}/counters/', { params: { path: { id } } });
runInAction(() => {
this.counters = {
...this.counters,
[id]: {
...counters[id],
...data[id],
},
};
});
return counters;
return data;
}
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
method: 'POST',
data: {
mode,
duration,
},
});
stopMaintenanceMode = (id: AlertReceiveChannel['id']) =>
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
method: 'POST',
});
addLabel = (id: AlertReceiveChannel['id'], data) => {
makeRequest(`${this.path}${id}/associate_label`, {
method: 'POST',
data,
});
};
}

View file

@ -1,66 +1,12 @@
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { LabelKeyValue } from 'models/label/label.types';
import { User } from 'models/user/user.types';
import { operations } from 'network/oncall-api/autogenerated-api.types';
export enum MaintenanceMode {
Debug = 0,
Maintenance = 1,
}
export interface AlertReceiveChannelOption {
display_name: string;
value: string;
featured: boolean;
short_description: string;
featured_tag_name: string;
}
export interface AlertReceiveChannelCounters {
alerts_count: number;
alert_groups_count: number;
}
export interface AlertReceiveChannel {
id: string;
integration: string;
smile_code: string;
verbal_name: string;
description: string;
description_short: string;
author: User['pk'];
team: GrafanaTeam['id'];
created_at: string;
integration_url: string;
inbound_email: string;
allow_source_based_resolving: boolean;
is_able_to_autoresolve: boolean;
is_based_on_alertmanager: boolean;
default_channel_filter: number;
instructions: string;
demo_alert_enabled: boolean;
demo_alert_payload: any;
maintenance_mode?: MaintenanceMode;
maintenance_till?: number;
heartbeat: Heartbeat | null;
is_available_for_integration_heartbeat: boolean;
routes_count: number;
connected_escalations_chains_count: number;
allow_delete: boolean;
deleted?: boolean;
labels: LabelKeyValue[];
alert_group_labels: {
inheritable: Record<LabelKeyValue['key']['id'], boolean>;
custom: LabelKeyValue[];
template: string;
};
alertmanager_v2_migrated_at?: string | null;
}
export interface AlertReceiveChannelChoice {
display_name: string;
value: number;
}
export type AlertReceiveChannelCounters =
operations['alert_receive_channels_counters_retrieve']['responses']['200']['content']['application/json'][string];
export interface ContactPoint {
dataSourceName: string;
@ -68,11 +14,3 @@ export interface ContactPoint {
contactPoint: string;
notificationConnected: boolean;
}
export interface SupportedIntegrationFilters {
integration?: string[];
integration_ne?: string[];
team?: string[];
label?: string[];
searchTerm?: string;
}

View file

@ -20,15 +20,15 @@ export class AlertReceiveChannelFiltersStore extends BaseStore {
this.path = '/alert_receive_channels/';
}
getSearchResult() {
getSearchResult = () => {
if (!this.searchResult) {
return undefined;
}
return this.searchResult.map((value: SelectOption['value']) => this.items?.[value]);
}
};
@action
@action.bound
async updateItems(query = '') {
const results = await makeRequest(`${this.path}`, {
params: { search: query, filters: true },

View file

@ -1,7 +1,6 @@
import { action, observable, makeObservable, runInAction } from 'mobx';
import qs from 'query-string';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { BaseStore } from 'models/base_store';
import { ActionKey } from 'models/loader/action-keys';
import { User } from 'models/user/user.types';
@ -97,27 +96,58 @@ export class AlertGroupStore extends BaseStore {
}).catch(showApiError);
}
@action
@action.bound
async updateItem(id: Alert['pk']) {
const item = await this.getById(id);
runInAction(() => {
this.items = {
...this.items,
[item.id]: item,
[item.pk]: item,
};
});
}
getSearchResult(query = '') {
@action.bound
async fetchItems(query = '', params = {}) {
const { results } = await makeRequest(`${this.path}`, {
params: { search: query, ...params },
});
runInAction(() => {
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: Alert }, item: Alert) => ({
...acc,
[item.pk]: item,
}),
{}
),
};
this.searchResult = {
...this.searchResult,
[query]: results.map((item: Alert) => item.pk),
};
});
}
@action.bound
async fetchItemsAvailableForAttachment(query: string) {
await this.fetchItems(query, {
status: [IncidentStatus.Acknowledged, IncidentStatus.Firing, IncidentStatus.Silenced],
});
}
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((id: Alert['pk']) => this.items[id]);
}
};
async getAlertGroupsForIntegration(integrationId: AlertReceiveChannel['id']) {
async getAlertGroupsForIntegration(integrationId: ApiSchemas['AlertReceiveChannel']['id']) {
const { results } = await makeRequest(`${this.path}`, {
params: { integration: integrationId },
});
@ -202,7 +232,7 @@ export class AlertGroupStore extends BaseStore {
await this.fetchTableSettings();
}
@action
@action.bound
async updateBulkActions() {
const response = await makeRequest(`${this.path}bulk_action_options/`, {});
@ -242,12 +272,12 @@ export class AlertGroupStore extends BaseStore {
this.setLiveUpdatesPaused(false);
}
@action
@action.bound
setLiveUpdatesPaused(value: boolean) {
this.liveUpdatesPaused = value;
}
@action
@action.bound
@AutoLoadingState(ActionKey.UPDATE_FILTERS_AND_FETCH_INCIDENTS)
async updateIncidentFiltersAndRefetchIncidentsAndStats(params: any, keepCursor = false) {
if (!keepCursor) {
@ -257,21 +287,21 @@ export class AlertGroupStore extends BaseStore {
await this.fetchIncidentsAndStats();
}
@action
@action.bound
async updateIncidentsCursor(cursor: string) {
this.setIncidentsCursor(cursor);
this.fetchAlertGroups();
}
@action
@action.bound
async setIncidentsCursor(cursor: string) {
this.incidentsCursor = cursor;
LocationHelper.update({ cursor }, 'partial');
}
@action
@action.bound
async setIncidentsItemsPerPage() {
this.setIncidentsCursor(undefined);
@ -360,7 +390,7 @@ export class AlertGroupStore extends BaseStore {
return await makeRequest(`/alerts/${pk}`, {});
}
@action
@action.bound
async getNewIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -374,7 +404,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action
@action.bound
async getAcknowledgedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -388,7 +418,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action
@action.bound
async getResolvedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -402,7 +432,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action
@action.bound
async getSilencedIncidentsStats() {
const result = await makeRequest(`${this.path}stats/`, {
params: {
@ -416,7 +446,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action
@action.bound
async doIncidentAction(alertId: Alert['pk'], action: AlertAction, isUndo = false, data?: any) {
this.updateAlert(alertId, { loading: true });
@ -463,7 +493,7 @@ export class AlertGroupStore extends BaseStore {
}
}
@action
@action.bound
async updateAlert(pk: Alert['pk'], value: Partial<Alert>) {
this.alerts.set(pk, {
...(this.alerts.get(pk) as Alert),
@ -478,7 +508,7 @@ export class AlertGroupStore extends BaseStore {
}).catch(this.onApiError);
}
@action
@action.bound
async fetchTableSettings(): Promise<void> {
const tableSettings = await makeRequest('/alertgroup_table_settings', {});
@ -493,7 +523,7 @@ export class AlertGroupStore extends BaseStore {
});
}
@action
@action.bound
@AutoLoadingState(ActionKey.ADD_NEW_COLUMN_TO_ALERT_GROUP)
async updateTableSettings(
columns: { visible: AlertGroupColumn[]; hidden: AlertGroupColumn[] },

View file

@ -1,8 +1,8 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Channel } from 'models/channel/channel';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { LabelKeyValue } from 'models/label/label.types';
import { PagedUser, User } from 'models/user/user.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
export enum IncidentStatus {
'Firing',
@ -79,7 +79,7 @@ export interface Alert {
status: IncidentStatus;
short?: boolean;
root_alert_group?: Alert;
alert_receive_channel: Partial<AlertReceiveChannel>;
alert_receive_channel: Partial<ApiSchemas['FastAlertReceiveChannel']>;
paged_users: PagedUser[];
team: GrafanaTeam['id'];
grafana_incident_id: string | null;

View file

@ -21,7 +21,7 @@ export class ApiTokenStore extends BaseStore {
this.path = '/tokens/';
}
@action
@action.bound
async updateItems(query = '') {
const results = await makeRequest(`${this.path}`, {
params: { search: query },
@ -46,13 +46,13 @@ export class ApiTokenStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((apiTokenId: ApiToken['id']) => this.items[apiTokenId]);
}
};
async revokeApiToken(id: ApiToken['id']) {
return await makeRequest(`${this.path}${id}/`, {

View file

@ -42,7 +42,7 @@ export class BaseStore {
throw error;
}
@action
@action.bound
async getAll(query = '') {
return await makeRequest(`${this.path}`, {
params: { search: query },
@ -50,7 +50,7 @@ export class BaseStore {
}).catch(this.onApiError);
}
@action
@action.bound
async getById(id: string, skipErrorHandling = false, fromOrganization = false) {
return await makeRequest(`${this.path}${id}`, {
method: 'GET',
@ -58,7 +58,7 @@ export class BaseStore {
}).catch((error) => this.onApiError(error, skipErrorHandling));
}
@action
@action.bound
async create<RT = any>(data: any, skipErrorHandling = false): Promise<RT | void> {
return await makeRequest<RT>(this.path, {
method: 'POST',
@ -68,7 +68,7 @@ export class BaseStore {
});
}
@action
@action.bound
async update<RT = any>(id: any, data: any, params: any = null, skipErrorHandling = false): Promise<RT | void> {
const result = await makeRequest<RT>(`${this.path}${id}/`, {
method: 'PUT',
@ -83,7 +83,7 @@ export class BaseStore {
return result;
}
@action
@action.bound
async delete(id: any) {
const result = await makeRequest(`${this.path}${id}/`, {
method: 'DELETE',

View file

@ -1,7 +1,7 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { SlackChannel } from 'models/slack_channel/slack_channel.types';
import { TelegramChannel, TelegramChannelDetails } from 'models/telegram_channel/telegram_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
export enum FilteringTermType {
regex,
@ -10,7 +10,7 @@ export enum FilteringTermType {
export interface ChannelFilter {
id: string;
alert_receive_channel: AlertReceiveChannel['id'];
alert_receive_channel: ApiSchemas['AlertReceiveChannel']['id'];
slack_channel_id?: SlackChannel['id'];
slack_channel?: SlackChannel;
telegram_channel?: TelegramChannel['id'];

View file

@ -24,7 +24,7 @@ export class CloudStore extends BaseStore {
this.path = '/cloud_users/';
}
@action
@action.bound
async updateItems(page = 1) {
const { matched_users_count, results } = await makeRequest(this.path, {
params: { page },
@ -49,12 +49,12 @@ export class CloudStore extends BaseStore {
});
}
getSearchResult() {
getSearchResult = () => {
return {
matched_users_count: this.searchResult.matched_users_count,
results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]),
};
}
};
async syncCloudUsers() {
return await makeRequest(`${this.path}`, { method: 'POST' });

View file

@ -29,7 +29,7 @@ export class DirectPagingStore extends BaseStore {
this.path = '/direct_paging/';
}
@action
@action.bound
addUserToSelectedUsers = (user: UserCurrentlyOnCall) => {
this.selectedUserResponders = [
...this.selectedUserResponders,
@ -40,22 +40,22 @@ export class DirectPagingStore extends BaseStore {
];
};
@action
@action.bound
resetSelectedUsers = () => {
this.selectedUserResponders = [];
};
@action
@action.bound
updateSelectedTeam = (team: GrafanaTeam) => {
this.selectedTeamResponder = team;
};
@action
@action.bound
resetSelectedTeam = () => {
this.selectedTeamResponder = null;
};
@action
@action.bound
removeSelectedUser(index: number) {
this.selectedUserResponders = [
...this.selectedUserResponders.slice(0, index),
@ -63,7 +63,7 @@ export class DirectPagingStore extends BaseStore {
];
}
@action
@action.bound
updateSelectedUserImportantStatus(index: number, important: boolean) {
this.selectedUserResponders = [
...this.selectedUserResponders.slice(0, index),

View file

@ -30,7 +30,7 @@ export class EscalationChainStore extends BaseStore {
this.path = '/escalation_chains/';
}
@action
@action.bound
async loadItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
const escalationChain = await this.getById(id, skipErrorHandling);
@ -44,7 +44,7 @@ export class EscalationChainStore extends BaseStore {
return escalationChain;
}
@action
@action.bound
async updateById(id: EscalationChain['id']) {
const response = await this.getById(id);
@ -56,7 +56,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action
@action.bound
async save(id: EscalationChain['id'], data: Partial<EscalationChain>) {
const response = await super.update(id, data);
@ -68,7 +68,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action
@action.bound
async updateEscalationChainDetails(id: EscalationChain['id']) {
const response = await makeRequest(`${this.path}${id}/details/`, {});
@ -80,7 +80,7 @@ export class EscalationChainStore extends BaseStore {
});
}
@action
@action.bound
async updateItem(id: EscalationChain['id'], skipErrorHandling = false): Promise<EscalationChain> {
let escalationChain;
try {
@ -107,7 +107,7 @@ export class EscalationChainStore extends BaseStore {
return escalationChain;
}
@action
@action.bound
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
@ -140,13 +140,13 @@ export class EscalationChainStore extends BaseStore {
this.loading = false;
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((escalationChainId: EscalationChain['id']) => this.items[escalationChainId]);
}
};
clone = (escalationChainId: EscalationChain['id'], data: Partial<EscalationChain>): Promise<EscalationChain> =>
makeRequest<EscalationChain>(`${this.path}${escalationChainId}/copy/`, {

View file

@ -64,7 +64,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action
@action.bound
async updateEscalationPolicies(escalationChainId: EscalationChain['id']) {
const response = await makeRequest(this.path, {
params: { escalation_chain: escalationChainId },
@ -91,7 +91,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action
@action.bound
createEscalationPolicy(escalationChainId: EscalationChain['id'], data: Partial<EscalationPolicy>) {
return super.create({
...data,
@ -99,7 +99,7 @@ export class EscalationPolicyStore extends BaseStore {
});
}
@action
@action.bound
async saveEscalationPolicy(id: EscalationPolicy['id'], data: Partial<EscalationPolicy>) {
this.items[id] = {
...this.items[id],
@ -113,7 +113,7 @@ export class EscalationPolicyStore extends BaseStore {
}
}
@action
@action.bound
async moveEscalationPolicyToPosition(oldIndex: any, newIndex: any, escalationChainId: EscalationChain['id']) {
const escalationPolicyId = this.escalationChainToEscalationPolicy[escalationChainId][oldIndex];
@ -130,7 +130,7 @@ export class EscalationPolicyStore extends BaseStore {
this.updateEscalationPolicies(escalationChainId);
}
@action
@action.bound
async deleteEscalationPolicy(data: Partial<EscalationPolicy>) {
const index = this.escalationChainToEscalationPolicy[data.escalation_chain].findIndex(
(escalationPolicyId: EscalationPolicy['id']) => escalationPolicyId === data.id

View file

@ -39,7 +39,7 @@ export class FiltersStore extends BaseStore {
}
}
@action
@action.bound
setNeedToParseFilters(value: boolean) {
this.needToParseFilters = value;
}
@ -54,7 +54,7 @@ export class FiltersStore extends BaseStore {
return this._globalValues;
}
@action
@action.bound
public async updateOptionsForPage(page: string) {
const result = await makeRequest(`/${getApiPathByPage(page)}/filters/`, {});
@ -73,7 +73,7 @@ export class FiltersStore extends BaseStore {
return result;
}
@action
@action.bound
updateValuesForPage(page: string, value: FiltersValues) {
this.values = {
...this.values,
@ -81,12 +81,12 @@ export class FiltersStore extends BaseStore {
};
}
@action
@action.bound
setCurrentTablePageNum(page: PAGE, currentTablePageNum: number) {
this.currentTablePageNum[page] = currentTablePageNum;
}
@action
@action.bound
applyLabelFilter = (label: LabelKeyValue, page: PAGE) => {
const currentLabelFilterValues = this.values[page]?.label || [];
const labelToAddString = `${label.key.id}:${label.value.id}`;

View file

@ -20,7 +20,7 @@ export class GlobalSettingStore extends BaseStore {
this.path = '/live_settings/';
}
@action
@action.bound
async updateById(id: GlobalSetting['id']) {
const response = await this.getById(id);
@ -32,7 +32,7 @@ export class GlobalSettingStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(query = '') {
const results = await this.getAll();
@ -55,13 +55,13 @@ export class GlobalSettingStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((globalSettingId: GlobalSetting['id']) => this.items[globalSettingId]);
}
};
async getGlobalSettingItemByName(name: string) {
const results = await this.getAll();

View file

@ -22,7 +22,7 @@ export class GrafanaTeamStore extends BaseStore {
this.path = '/teams/';
}
@action
@action.bound
async updateTeam(id: GrafanaTeam['id'], data: Partial<GrafanaTeam>) {
const result = await this.update(id, data);
@ -61,7 +61,7 @@ export class GrafanaTeamStore extends BaseStore {
});
}
getSearchResult() {
getSearchResult = () => {
return this.searchResult.map((teamId: GrafanaTeam['id']) => this.items[teamId]);
}
};
}

View file

@ -1,8 +1,8 @@
import { action, observable, makeObservable, runInAction } from 'mobx';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { BaseStore } from 'models/base_store';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { RootStore } from 'state/rootStore';
import { Heartbeat } from './heartbeat.types';
@ -22,7 +22,7 @@ export class HeartbeatStore extends BaseStore {
this.path = '/heartbeats/';
}
@action
@action.bound
async updateTimeoutOptions() {
const result = await makeRequest(`${this.path}timeout_options/`, {});
@ -31,7 +31,7 @@ export class HeartbeatStore extends BaseStore {
});
}
@action
@action.bound
async saveHeartbeat(id: Heartbeat['id'], data: Partial<Heartbeat>) {
const response = await super.update<Heartbeat>(id, data);
@ -47,8 +47,8 @@ export class HeartbeatStore extends BaseStore {
});
}
@action
async createHeartbeat(alertReceiveChannelId: AlertReceiveChannel['id'], data: Partial<Heartbeat>) {
@action.bound
async createHeartbeat(alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id'], data: Partial<Heartbeat>) {
const response = await super.create<Heartbeat>({
alert_receive_channel: alertReceiveChannelId,
...data,

View file

@ -1,9 +1,9 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
export interface Heartbeat {
id: string;
last_heartbeat_time_verbal: string;
alert_receive_channel: AlertReceiveChannel['id'];
alert_receive_channel: ApiSchemas['AlertReceiveChannel']['id'];
link: string;
timeout_seconds: number;
status: boolean;

View file

@ -3,7 +3,7 @@ import { action, makeObservable } from 'mobx';
import { BaseStore } from 'models/base_store';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import onCallApi from 'network/oncall-api/http-client';
import { onCallApi } from 'network/oncall-api/http-client';
import { RootStore } from 'state/rootStore';
import { WithGlobalNotification } from 'utils/decorators';
@ -18,7 +18,7 @@ export class LabelStore extends BaseStore {
@action.bound
public async loadKeys(search = '') {
const { data } = await onCallApi.GET('/labels/keys/', undefined);
const { data } = await onCallApi().GET('/labels/keys/', undefined);
const filtered = data.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));

View file

@ -22,6 +22,10 @@ class LoaderStoreClass {
this.items[actionKey] = isLoading;
}
}
isLoading(actionKey: string): boolean {
return !!this.items[actionKey];
}
}
export const LoaderStore = new LoaderStoreClass();

View file

@ -26,7 +26,7 @@ export class MSTeamsChannelStore extends BaseStore {
this.path = '/msteams/channels/';
}
@action
@action.bound
async updateMSTeamsChannels() {
const response = await makeRequest(this.path, {});
@ -48,7 +48,7 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateById(id: MSTeamsChannel['id']) {
const response = await this.getById(id);
@ -60,7 +60,7 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(query = '') {
const result = await this.getAll();
@ -83,12 +83,12 @@ export class MSTeamsChannelStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((msteamsChannelId: MSTeamsChannel['id']) => this.items[msteamsChannelId]);
}
};
@computed
get hasItems() {

View file

@ -28,7 +28,7 @@ export class OutgoingWebhookStore extends BaseStore {
this.path = '/webhooks/';
}
@action
@action.bound
async loadItem(id: OutgoingWebhook['id'], skipErrorHandling = false): Promise<OutgoingWebhook> {
const outgoingWebhook = await this.getById(id, skipErrorHandling);
@ -42,7 +42,7 @@ export class OutgoingWebhookStore extends BaseStore {
return outgoingWebhook;
}
@action
@action.bound
async updateById(id: OutgoingWebhook['id']) {
const response = await this.getById(id);
@ -54,7 +54,7 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action
@action.bound
async updateItem(id: OutgoingWebhook['id'], fromOrganization = false) {
const response = await this.getById(id, false, fromOrganization);
@ -66,7 +66,7 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
@ -95,13 +95,13 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((outgoingWebhookId: OutgoingWebhook['id']) => this.items[outgoingWebhookId]);
}
};
async getLastResponses(id: OutgoingWebhook['id']) {
const result = await makeRequest(`${this.path}${id}/responses`, {});

View file

@ -133,7 +133,7 @@ export class ScheduleStore extends BaseStore {
this.path = '/schedules/';
}
@action
@action.bound
async loadItem(id: Schedule['id'], skipErrorHandling = false): Promise<Schedule> {
const schedule = await this.getById(id, skipErrorHandling);
@ -147,7 +147,7 @@ export class ScheduleStore extends BaseStore {
return schedule;
}
@action
@action.bound
async updateItems(
f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined },
page = 1,
@ -182,7 +182,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action
@action.bound
async updateItem(id: Schedule['id'], fromOrganization = false) {
if (id) {
let schedule;
@ -211,13 +211,13 @@ export class ScheduleStore extends BaseStore {
}
}
getSearchResult() {
getSearchResult = () => {
return {
page_size: this.searchResult.page_size,
count: this.searchResult.count,
results: this.searchResult.results?.map((scheduleId: Schedule['id']) => this.items[scheduleId]),
};
}
};
@action.bound
async getScoreQuality(scheduleId: Schedule['id']) {
@ -256,7 +256,7 @@ export class ScheduleStore extends BaseStore {
// ------- NEW SCHEDULES API ENDPOINTS ---------
@action
@action.bound
async createRotation(scheduleId: Schedule['id'], isOverride: boolean, params: Partial<Shift>) {
const type = isOverride ? 3 : 2;
@ -324,7 +324,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action
@action.bound
async updateShiftsSwapPreview(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, params: Partial<ShiftSwap>) {
const fromString = getFromString(startMoment);
@ -350,7 +350,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action
@action.bound
clearPreview() {
this.finalPreview = undefined;
this.rotationPreview = undefined;
@ -359,7 +359,7 @@ export class ScheduleStore extends BaseStore {
this.rotationFormLiveParams = undefined;
}
@action
@action.bound
async updateRotation(shiftId: Shift['id'], params: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
params: { force: true },
@ -377,7 +377,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action
@action.bound
async updateRotationAsNew(shiftId: Shift['id'], params: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {
data: { ...params },
@ -394,7 +394,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action
@action.bound
updateRelatedEscalationChains = async (id: Schedule['id']) => {
const response = await makeRequest(`/schedules/${id}/related_escalation_chains`, {
method: 'GET',
@ -410,7 +410,7 @@ export class ScheduleStore extends BaseStore {
return response;
};
@action
@action.bound
updateRelatedUsers = async (id: Schedule['id']) => {
const { users } = await makeRequest(`/schedules/${id}/next_shifts_per_user`, {
method: 'GET',
@ -426,7 +426,7 @@ export class ScheduleStore extends BaseStore {
return users;
};
@action
@action.bound
async updateOncallShifts(scheduleId: Schedule['id']) {
const { results } = await makeRequest(`/oncall_shifts/`, {
params: {
@ -449,7 +449,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action
@action.bound
async updateOncallShift(shiftId: Shift['id']) {
if (this.shiftsCurrentlyUpdating[shiftId]) {
return;
@ -471,7 +471,7 @@ export class ScheduleStore extends BaseStore {
return response;
}
@action
@action.bound
async saveOncallShift(shiftId: Shift['id'], data: Partial<Shift>) {
const response = await makeRequest(`/oncall_shifts/${shiftId}`, { method: 'PUT', data });
@ -492,7 +492,7 @@ export class ScheduleStore extends BaseStore {
}).catch(this.onApiError);
}
@action
@action.bound
async updateEvents(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, type: RotationType = 'rotation', days = 9) {
const dayBefore = startMoment.subtract(1, 'day');
@ -554,7 +554,7 @@ export class ScheduleStore extends BaseStore {
});
}
@action
@action.bound
async updateDaysOptions() {
const result = await makeRequest(`/oncall_shifts/days_options/`, {
method: 'GET',
@ -577,7 +577,7 @@ export class ScheduleStore extends BaseStore {
return await makeRequest(`/shift_swaps/${shiftSwapId}/take`, { method: 'POST' }).catch(this.onApiError);
}
@action
@action.bound
async loadShiftSwap(id: ShiftSwap['id']) {
const result = await makeRequest(`/shift_swaps/${id}`, { params: { expand_users: true } });
@ -588,7 +588,7 @@ export class ScheduleStore extends BaseStore {
return result;
}
@action
@action.bound
async updateShiftSwaps(scheduleId: Schedule['id'], startMoment: dayjs.Dayjs, days = 9) {
const fromString = getFromString(startMoment);
@ -630,7 +630,7 @@ export class ScheduleStore extends BaseStore {
}
@AutoLoadingState(ActionKey.UPDATE_PERSONAL_EVENTS)
@action
@action.bound
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9, isUpdateOnCallNow = false) {
const fromString = getFromString(startMoment);

View file

@ -19,7 +19,7 @@ export class SlackStore extends BaseStore {
makeObservable(this);
}
@action
@action.bound
async updateSlackSettings() {
const result = await makeRequest('/slack_settings/', {});
@ -28,7 +28,7 @@ export class SlackStore extends BaseStore {
});
}
@action
@action.bound
async saveSlackSettings(data: Partial<SlackSettings>) {
const result = await makeRequest('/slack_settings/', {
data,
@ -40,7 +40,7 @@ export class SlackStore extends BaseStore {
});
}
@action
@action.bound
async setGeneralLogChannelId(id: SlackChannel['id']) {
return await makeRequest('/set_general_channel/', {
method: 'POST',
@ -48,7 +48,7 @@ export class SlackStore extends BaseStore {
});
}
@action
@action.bound
async updateSlackIntegrationData(slack_id: string) {
const result = await makeRequest('/slack_integration/', {
params: { slack_id },

View file

@ -21,7 +21,7 @@ export class SlackChannelStore extends BaseStore {
this.path = '/slack_channels/';
}
@action // deprecated, use updateItem instead
@action.bound // deprecated, use updateItem instead
async updateById(id: SlackChannel['id']) {
const response = await this.getById(id);
@ -33,7 +33,7 @@ export class SlackChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateItem(id: SlackChannel['id']) {
const response = await this.getById(id);
@ -45,7 +45,7 @@ export class SlackChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(query = '') {
const { results } = await makeRequest(`${this.path}`, {
params: { search: query },
@ -70,11 +70,11 @@ export class SlackChannelStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((slackChannelId: SlackChannel['id']) => this.items[slackChannelId]);
}
};
}

View file

@ -26,7 +26,7 @@ export class TelegramChannelStore extends BaseStore {
this.path = '/telegram_channels/';
}
@action
@action.bound
async updateTelegramChannels() {
const response = await makeRequest(this.path, {});
@ -48,7 +48,7 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateById(id: TelegramChannel['id']) {
const response = await this.getById(id);
@ -60,7 +60,7 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(query = '') {
const result = await this.getAll();
@ -83,12 +83,12 @@ export class TelegramChannelStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((telegramChannelId: TelegramChannel['id']) => this.items[telegramChannelId]);
}
};
@computed
get hasItems() {
@ -111,14 +111,14 @@ export class TelegramChannelStore extends BaseStore {
});
}
@action
@action.bound
async makeTelegramChannelDefault(id: TelegramChannel['id']) {
return makeRequest(`/telegram_channels/${id}/set_default/`, {
method: 'POST',
});
}
@action
@action.bound
async deleteTelegramChannel(id: TelegramChannel['id']) {
return super.delete(id);
}

View file

@ -595,8 +595,6 @@ export const allTimezones = [
'Zulu',
];
// TODO: move it to utils
export const getTzOffsetString = (date: dayjs.Dayjs) => {
const userOffset = date.utcOffset();
const userOffsetHours = userOffset / 60;

View file

@ -41,7 +41,7 @@ describe('UserStore.unlinkBackend', () => {
test('it makes the proper API call and returns the response', async () => {
makeRequest.mockResolvedValueOnce('hello');
userStore.loadCurrentUser = jest.fn();
Object.defineProperty(userStore, 'loadCurrentUser', { value: jest.fn() });
await userStore.unlinkBackend(userPk, backend);

View file

@ -60,7 +60,7 @@ export class UserStore extends BaseStore {
return this.items[this.currentUserPk as User['pk']];
}
@action
@action.bound
async loadCurrentUser() {
const response = await makeRequest('/user/', {});
const timezone = await this.refreshTimezone(response.pk);
@ -74,7 +74,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async refreshTimezone(id: User['pk']) {
const { timezone: grafanaPreferencesTimezone } = config.bootData.user;
const timezone = grafanaPreferencesTimezone === 'browser' ? dayjs.tz.guess() : grafanaPreferencesTimezone;
@ -87,7 +87,7 @@ export class UserStore extends BaseStore {
return timezone;
}
@action
@action.bound
async loadUser(userPk: User['pk'], skipErrorHandling = false): Promise<User> {
const user = await this.getById(userPk, skipErrorHandling);
@ -101,7 +101,7 @@ export class UserStore extends BaseStore {
return user;
}
@action
@action.bound
async updateItem(userPk: User['pk']) {
if (this.itemsCurrentlyUpdating[userPk]) {
return;
@ -132,7 +132,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async updateItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
const response = await this.search(f, page);
@ -167,19 +167,19 @@ export class UserStore extends BaseStore {
return response;
}
getSearchResult() {
getSearchResult = () => {
return {
page_size: this.searchResult.page_size,
count: this.searchResult.count,
results: this.searchResult.results?.map((userPk: User['pk']) => this.items?.[userPk]),
};
}
};
sendTelegramConfirmationCode = async (userPk: User['pk']) => {
return await makeRequest(`/users/${userPk}/get_telegram_verification_code/`, {});
};
@action
@action.bound
unlinkSlack = async (userPk: User['pk']) => {
await makeRequest(`/users/${userPk}/unlink_slack/`, {
method: 'POST',
@ -195,7 +195,7 @@ export class UserStore extends BaseStore {
});
};
@action
@action.bound
unlinkTelegram = async (userPk: User['pk']) => {
await makeRequest(`/users/${userPk}/unlink_telegram/`, {
method: 'POST',
@ -216,7 +216,7 @@ export class UserStore extends BaseStore {
method: 'GET',
});
@action
@action.bound
unlinkBackend = async (userPk: User['pk'], backend: string) => {
await makeRequest(`/users/${userPk}/unlink_backend/?backend=${backend}`, {
method: 'POST',
@ -225,7 +225,7 @@ export class UserStore extends BaseStore {
this.loadCurrentUser();
};
@action
@action.bound
async createUser(data: any) {
const user = await this.create(data);
@ -239,7 +239,7 @@ export class UserStore extends BaseStore {
return user;
}
@action
@action.bound
async updateUser(data: Partial<User>) {
const user = await makeRequest(`/users/${data.pk}/`, {
method: 'PUT',
@ -261,7 +261,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async updateCurrentUser(data: Partial<User>) {
const user = await makeRequest(`/user/`, {
method: 'PUT',
@ -279,7 +279,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async fetchVerificationCode(userPk: User['pk'], recaptchaToken: string) {
await makeRequest(`/users/${userPk}/get_verification_code/`, {
method: 'GET',
@ -287,7 +287,7 @@ export class UserStore extends BaseStore {
}).catch(throttlingError);
}
@action
@action.bound
async fetchVerificationCall(userPk: User['pk'], recaptchaToken: string) {
await makeRequest(`/users/${userPk}/get_verification_call/`, {
method: 'GET',
@ -295,21 +295,21 @@ export class UserStore extends BaseStore {
}).catch(throttlingError);
}
@action
@action.bound
async verifyPhone(userPk: User['pk'], token: string) {
return await makeRequest(`/users/${userPk}/verify_number/?token=${token}`, {
method: 'PUT',
}).catch(throttlingError);
}
@action
@action.bound
async forgetPhone(userPk: User['pk']) {
return await makeRequest(`/users/${userPk}/forget_number/`, {
method: 'PUT',
});
}
@action
@action.bound
async updateNotificationPolicies(id: User['pk']) {
const importantEPs = await makeRequest('/notification_policies/', {
params: { user: id, important: true },
@ -327,7 +327,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async moveNotificationPolicyToPosition(userPk: User['pk'], oldIndex: number, newIndex: number, offset: number) {
const notificationPolicy = this.notificationPolicies[userPk][oldIndex + offset];
@ -342,7 +342,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action
@action.bound
async addNotificationPolicy(userPk: User['pk'], important: NotificationPolicyType['important']) {
await makeRequest(`/notification_policies/`, {
method: 'POST',
@ -354,7 +354,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action
@action.bound
async updateNotificationPolicy(userPk: User['pk'], id: NotificationPolicyType['id'], value: NotificationPolicyType) {
this.notificationPolicies = {
...this.notificationPolicies,
@ -380,7 +380,7 @@ export class UserStore extends BaseStore {
this.updateItem(userPk); // to update notification_chain_verbal
}
@action
@action.bound
async deleteNotificationPolicy(userPk: User['pk'], id: NotificationPolicyType['id']) {
await makeRequest(`/notification_policies/${id}`, { method: 'DELETE' }).catch(this.onApiError);
@ -400,7 +400,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async sendTestPushNotification(userId: User['pk'], isCritical: boolean) {
return await makeRequest(`/users/${userId}/send_test_push`, {
method: 'POST',
@ -419,7 +419,7 @@ export class UserStore extends BaseStore {
});
}
@action
@action.bound
async makeTestCall(userPk: User['pk']) {
this.isTestCallInProgress = true;

View file

@ -21,7 +21,7 @@ export class UserGroupStore extends BaseStore {
this.path = '/user_groups/';
}
@action
@action.bound
async updateItems(query = '') {
const result = await makeRequest(`${this.path}`, {
params: { search: query },
@ -46,11 +46,11 @@ export class UserGroupStore extends BaseStore {
});
}
getSearchResult(query = '') {
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
}
return this.searchResult[query].map((userGroupId: UserGroup['id']) => this.items?.[userGroupId]);
}
};
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import { SpanStatusCode } from '@opentelemetry/api';
import { FaroHelper } from 'utils/faro';
import { customFetch } from './http-client';
import { getCustomFetchFn } from './http-client';
jest.mock('utils/faro', () => ({
__esModule: true,
@ -31,6 +31,7 @@ const REQUEST_CONFIG = {
const URL = 'https://someurl.com';
const SUCCESSFUL_RESPONSE_MOCK = { ok: true };
const ERROR_MOCK = 'error';
const customFetch = getCustomFetchFn({ withGlobalErrorHandler: true });
describe('customFetch', () => {
beforeAll(() => {
@ -54,8 +55,8 @@ describe('customFetch', () => {
describe('if response is not successful', () => {
it('should push event and error to faro', async () => {
(FaroHelper.faro.api.getOTEL as unknown as jest.Mock).mockReturnValueOnce(undefined);
fetchMock.mockRejectedValueOnce(ERROR_MOCK);
await expect(customFetch(URL, REQUEST_CONFIG)).rejects.toEqual(Error(ERROR_MOCK));
fetchMock.mockResolvedValueOnce({ ok: false, json: () => ERROR_MOCK });
await expect(customFetch(URL, REQUEST_CONFIG)).rejects.toEqual(ERROR_MOCK);
expect(FaroHelper.faro.api.pushEvent).toHaveBeenCalledWith('Request failed', { url: URL });
expect(FaroHelper.faro.api.pushError).toHaveBeenCalledWith(ERROR_MOCK);
});
@ -113,7 +114,7 @@ describe('customFetch', () => {
describe('if response is not successful', () => {
it('should reject Promise, push event to faro, set span status to error and end span', async () => {
fetchMock.mockRejectedValueOnce(ERROR_MOCK);
fetchMock.mockResolvedValueOnce({ ok: false, json: () => ERROR_MOCK });
await expect(customFetch(URL, REQUEST_CONFIG)).rejects.toEqual(ERROR_MOCK);
expect(FaroHelper.faro.api.pushEvent).toHaveBeenCalledWith('Request failed', { url: URL });
expect(FaroHelper.faro.api.pushError).toHaveBeenCalledWith(ERROR_MOCK);

View file

@ -4,75 +4,108 @@ import createClient from 'openapi-fetch';
import qs from 'query-string';
import { FaroHelper } from 'utils/faro';
import { formatBackendError, openErrorNotification } from 'utils/utils';
import { paths } from './autogenerated-api.types';
export const API_PROXY_PREFIX = 'api/plugin-proxy/grafana-oncall-app';
export const API_PATH_PREFIX = '/api/internal/v1';
export const customFetch = async (url: string, requestConfig: Parameters<typeof fetch>[1] = {}): Promise<Response> => {
const { faro } = FaroHelper;
const otel = faro?.api?.getOTEL();
if (faro && otel) {
const tracer = otel.trace.getTracer('default');
let span = otel.trace.getActiveSpan();
if (!span) {
span = tracer.startSpan('http-request');
span.setAttribute('page_url', document.URL.split('//')[1]);
span.setAttribute(SemanticAttributes.HTTP_URL, url);
span.setAttribute(SemanticAttributes.HTTP_METHOD, requestConfig.method);
}
return new Promise((resolve, reject) => {
otel.context.with(otel.trace.setSpan(otel.context.active(), span), async () => {
faro.api.pushEvent('Sending request', { url });
try {
const response = await fetch(url, {
...requestConfig,
headers: {
...requestConfig.headers,
/**
* In short, this header will tell the Grafana plugin proxy, a Go service which use Go's HTTP Transport,
* to retry POST requests (and other non-idempotent requests). This doesn't necessarily make these requests
* idempotent, but it will make them retry-able from Go's (read: net/http) perspective.
*
* https://stackoverflow.com/questions/42847294/how-to-catch-http-server-closed-idle-connection-error/62292758#62292758
* https://raintank-corp.slack.com/archives/C01C4K8DETW/p1692280544382739?thread_ts=1692279329.797149&cid=C01C4K8DETW
*/ 'X-Idempotency-Key': `${Date.now()}-${Math.random()}`,
},
});
faro.api.pushEvent('Request completed', { url });
span.end();
resolve(response);
} catch (error) {
faro.api.pushEvent('Request failed', { url });
faro.api.pushError(error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
reject(error);
}
});
});
} else {
try {
const response = await fetch(url, requestConfig);
faro?.api.pushEvent('Request completed', { url });
return response;
} catch (error) {
faro?.api.pushEvent('Request failed', { url });
faro?.api.pushError(error);
throw new Error(error);
const showApiError = (errorResponse: Response) => {
if (errorResponse.status >= 400 && errorResponse.status < 500) {
const text = formatBackendError(errorResponse.statusText);
if (text) {
openErrorNotification(text);
}
}
};
const onCallApi = createClient<paths>({
export const getCustomFetchFn =
({ withGlobalErrorHandler }: { withGlobalErrorHandler: boolean }) =>
async (url: string, reqConfig: Parameters<typeof fetch>[1] = {}): Promise<Response> => {
const { faro } = FaroHelper;
const otel = faro?.api?.getOTEL();
const requestConfig = {
...reqConfig,
headers: {
...reqConfig.headers,
'Content-Type': 'application/json',
/**
* In short, this header will tell the Grafana plugin proxy, a Go service which use Go's HTTP Transport,
* to retry POST requests (and other non-idempotent requests). This doesn't necessarily make these requests
* idempotent, but it will make them retry-able from Go's (read: net/http) perspective.
*
* https://stackoverflow.com/questions/42847294/how-to-catch-http-server-closed-idle-connection-error/62292758#62292758
* https://raintank-corp.slack.com/archives/C01C4K8DETW/p1692280544382739?thread_ts=1692279329.797149&cid=C01C4K8DETW
*/ 'X-Idempotency-Key': `${Date.now()}-${Math.random()}`,
},
};
if (faro && otel) {
const tracer = otel.trace.getTracer('default');
let span = otel.trace.getActiveSpan();
if (!span) {
span = tracer.startSpan('http-request');
span.setAttribute('page_url', document.URL.split('//')[1]);
span.setAttribute(SemanticAttributes.HTTP_URL, url);
span.setAttribute(SemanticAttributes.HTTP_METHOD, requestConfig.method);
}
return new Promise((resolve, reject) => {
otel.context.with(otel.trace.setSpan(otel.context.active(), span), async () => {
faro.api.pushEvent('Sending request', { url });
const res = await fetch(url, requestConfig);
if (res.ok) {
faro.api.pushEvent('Request completed', { url });
span.end();
resolve(res);
} else {
const errorData = await res.json();
faro.api.pushEvent('Request failed', { url });
faro.api.pushError(errorData);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
if (withGlobalErrorHandler) {
showApiError(res);
}
reject(errorData);
}
});
});
} else {
const res = await fetch(url, requestConfig);
if (res.ok) {
faro?.api.pushEvent('Request completed', { url });
return res;
} else {
const errorData = await res.json();
faro?.api.pushEvent('Request failed', { url });
faro?.api.pushError(errorData);
if (withGlobalErrorHandler) {
showApiError(res);
}
throw errorData;
}
}
};
const clientConfig = {
baseUrl: `${API_PROXY_PREFIX}${API_PATH_PREFIX}`,
querySerializer: (params: unknown) => qs.stringify(params, { arrayFormat: 'none' }),
fetch: customFetch,
};
// We might want to switch to middleware instead of 2 clients once this is published: https://github.com/drwpow/openapi-typescript/pull/1521
const onCallApiWithGlobalErrorHandling = createClient<paths>({
...clientConfig,
fetch: getCustomFetchFn({ withGlobalErrorHandler: true }),
});
const onCallApiSkipErrorHandling = createClient<paths>({
...clientConfig,
fetch: getCustomFetchFn({ withGlobalErrorHandler: false }),
});
export default onCallApi;
export const onCallApi = ({ skipErrorHandling = false }: { skipErrorHandling?: boolean } = {}) =>
skipErrorHandling ? onCallApiSkipErrorHandling : onCallApiWithGlobalErrorHandling;

View file

@ -44,6 +44,7 @@ import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers
import { UserResponder } from 'containers/AddResponders/AddResponders.types';
import { AttachIncidentForm } from 'containers/AttachIncidentForm/AttachIncidentForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { Alert, AlertAction, TimeLineItem, TimeLineRealm, GroupedAlert } from 'models/alertgroup/alertgroup.types';
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
import { User } from 'models/user/user.types';
@ -270,17 +271,14 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
params: { id },
},
} = this.props;
const { alerts } = store.alertGroupStore;
const incident = alerts.get(id);
const integration = store.alertReceiveChannelStore.getIntegration(incident.alert_receive_channel);
const integration = AlertReceiveChannelHelper.getIntegration(
store.alertReceiveChannelStore,
incident.alert_receive_channel
);
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
const sourceLink = incident?.render_for_web?.source_link;
return (

View file

@ -35,6 +35,7 @@ import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilter
import { RemoteFilters } from 'containers/RemoteFilters/RemoteFilters';
import { TeamName } from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import {
Alert,
Alert as AlertType,
@ -629,7 +630,10 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
const {
store: { alertReceiveChannelStore },
} = this.props;
const integration = alertReceiveChannelStore.getIntegration(record.alert_receive_channel);
const integration = AlertReceiveChannelHelper.getIntegration(
alertReceiveChannelStore,
record.alert_receive_channel
);
return (
<TextEllipsisTooltip

View file

@ -1,15 +1,16 @@
import { IconName } from '@grafana/ui';
import dayjs from 'dayjs';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { RootStore } from 'state/rootStore';
import { MAX_CHARACTERS_COUNT, TEXTAREA_ROWS_COUNT } from './IntegrationCommon.config';
export const IntegrationHelper = {
isSpecificIntegration: (alertReceiveChannel: AlertReceiveChannel | string, name: string) => {
isSpecificIntegration: (alertReceiveChannel: ApiSchemas['AlertReceiveChannel'] | string, name: string) => {
if (!alertReceiveChannel) {
return false;
}
@ -46,7 +47,7 @@ export const IntegrationHelper = {
return slice.length === line.length ? slice : `${slice} ...`;
},
getMaintenanceText(maintenanceUntill: number, mode: number = undefined) {
getMaintenanceText(maintenanceUntill: number, mode?: MaintenanceMode) {
const date = dayjs(new Date(maintenanceUntill * 1000));
const now = dayjs();
const hourDiff = date.diff(now, 'hours');
@ -124,4 +125,5 @@ export const IntegrationHelper = {
},
};
export const getIsBidirectionalIntegration = ({ integration }: AlertReceiveChannel) => integration === 'servicenow';
export const getIsBidirectionalIntegration = ({ integration }: ApiSchemas['AlertReceiveChannel']) =>
integration === ('servicenow' as ApiSchemas['AlertReceiveChannel']['integration']); // TODO: add service now in backend schema as valid value and remove casting

View file

@ -54,12 +54,11 @@ import { TeamName } from 'containers/TeamName/TeamName';
import { UserDisplayWithAvatar } from 'containers/UserDisplay/UserDisplayWithAvatar';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons/Icons';
import {
AlertReceiveChannel,
AlertReceiveChannelCounters,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { AlertReceiveChannelCounters } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates/alert_templates';
import { ChannelFilter } from 'models/channel_filter/channel_filter.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { IntegrationHelper, getIsBidirectionalIntegration } from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
import { AppFeature } from 'state/features';
@ -154,7 +153,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
);
}
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[id];
const isLegacyIntegration = integration && (integration?.value as string).toLowerCase().startsWith('legacy_');
const contactPoints = alertReceiveChannelStore.connectedContactPoints?.[alertReceiveChannel.id];
@ -331,7 +330,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
}
}
renderAlertmanagerV2MigrationHeaderMaybe(alertReceiveChannel: AlertReceiveChannel) {
renderAlertmanagerV2MigrationHeaderMaybe(alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) {
if (!alertReceiveChannel.alertmanager_v2_migrated_at) {
return null;
}
@ -390,7 +389,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
);
}
renderDescriptionMaybe(alertReceiveChannel: AlertReceiveChannel) {
renderDescriptionMaybe(alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) {
if (!alertReceiveChannel.description_short) {
return null;
}
@ -402,7 +401,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
);
}
renderContactPointsWarningMaybe(alertReceiveChannel: AlertReceiveChannel) {
renderContactPointsWarningMaybe(alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) {
if (IntegrationHelper.isSpecificIntegration(alertReceiveChannel, 'grafana_alerting')) {
return (
<div className={cx('u-padding-top-md')}>
@ -583,14 +582,13 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
isAddingRoute: true,
},
() => {
alertReceiveChannelStore
.createChannelFilter({
alert_receive_channel: id,
filtering_term: NEW_ROUTE_DEFAULT,
filtering_term_type: 1, // non-regex
})
AlertReceiveChannelHelper.createChannelFilter({
alert_receive_channel: id,
filtering_term: NEW_ROUTE_DEFAULT,
filtering_term_type: 1, // non-regex
})
.then(async (channelFilter: ChannelFilter) => {
await alertReceiveChannelStore.updateChannelFilters(id);
await alertReceiveChannelStore.fetchChannelFilters(id);
this.setState(
(prevState) => ({
@ -693,7 +691,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
filtering_term_type: filteringTermType,
})
.then((channelFilter: ChannelFilter) => {
alertReceiveChannelStore.updateChannelFilters(id, true).then(() => {
alertReceiveChannelStore.fetchChannelFilters(id, true).then(() => {
escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
});
this.setState({
@ -753,13 +751,10 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
}
};
onRemovalFn = (id: AlertReceiveChannel['id']) => {
const {
store: { alertReceiveChannelStore },
history,
} = this.props;
alertReceiveChannelStore.deleteAlertReceiveChannel(id).then(() => history.push(`${PLUGIN_ROOT}/integrations/`));
onRemovalFn = (id: ApiSchemas['AlertReceiveChannel']['id']) => {
AlertReceiveChannelHelper.deleteAlertReceiveChannel(id).then(() =>
this.props.history.push(`${PLUGIN_ROOT}/integrations/`)
);
};
async loadData() {
@ -775,18 +770,18 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
const promises = [];
if (!alertReceiveChannelStore.items[id]) {
promises.push(alertReceiveChannelStore.loadItem(id).then(() => this.loadExtraData(id)));
promises.push(alertReceiveChannelStore.fetchItemById(id).then(() => this.loadExtraData(id)));
} else {
promises.push(this.loadExtraData(id));
}
if (!alertReceiveChannelStore.channelFilterIds[id]) {
promises.push(alertReceiveChannelStore.updateChannelFilters(id));
promises.push(alertReceiveChannelStore.fetchChannelFilters(id));
}
promises.push(alertReceiveChannelStore.updateTemplates(id));
promises.push(alertReceiveChannelStore.fetchTemplates(id));
promises.push(IntegrationHelper.fetchChatOps(store));
promises.push(alertReceiveChannelStore.updateCountersForIntegration(id));
promises.push(alertReceiveChannelStore.fetchCountersForIntegration(id));
await Promise.all(promises)
.catch(() => {
@ -798,12 +793,12 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
.finally(() => this.setState({ isLoading: false }));
}
async loadExtraData(id: AlertReceiveChannel['id']) {
async loadExtraData(id: ApiSchemas['AlertReceiveChannel']['id']) {
const { alertReceiveChannelStore } = this.props.store;
if (IntegrationHelper.isSpecificIntegration(alertReceiveChannelStore.items[id], 'grafana_alerting')) {
// this will be delayed and not awaitable so that we don't delay the whole page load
return await alertReceiveChannelStore.updateConnectedContactPoints(id);
return await alertReceiveChannelStore.fetchConnectedContactPoints(id);
}
return Promise.resolve();
@ -812,7 +807,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
interface IntegrationActionsProps {
isLegacyIntegration: boolean;
alertReceiveChannel: AlertReceiveChannel;
alertReceiveChannel: ApiSchemas['AlertReceiveChannel'];
changeIsTemplateSettingsOpen: () => void;
}
@ -843,7 +838,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
const [isDemoModalOpen, setIsDemoModalOpen] = useState(false);
const [maintenanceData, setMaintenanceData] = useState<{
disabled: boolean;
alert_receive_channel_id: AlertReceiveChannel['id'];
alert_receive_channel_id: ApiSchemas['AlertReceiveChannel']['id'];
}>(undefined);
const { id } = alertReceiveChannel;
@ -876,9 +871,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
<IntegrationForm
isTableView={false}
onHide={() => setIsIntegrationSettingsOpen(false)}
onSubmit={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
onSubmit={async () => {
await alertReceiveChannelStore.fetchItemById(alertReceiveChannel.id);
}}
id={alertReceiveChannel['id']}
navigateToAlertGroupLabels={(_id: AlertReceiveChannel['id']) => {
navigateToAlertGroupLabels={(_id: ApiSchemas['AlertReceiveChannel']['id']) => {
setIsIntegrationSettingsOpen(false);
setLabelsFormOpen(true);
}}
@ -890,7 +887,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
onHide={() => {
setLabelsFormOpen(false);
}}
onSubmit={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
onSubmit={() => alertReceiveChannelStore.fetchItemById(alertReceiveChannel.id)}
id={alertReceiveChannel['id']}
onOpenIntegrationSettings={() => {
setIsIntegrationSettingsOpen(true);
@ -908,7 +905,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{maintenanceData && (
<MaintenanceForm
initialData={maintenanceData}
onUpdate={() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id)}
onUpdate={() => alertReceiveChannelStore.fetchItemById(alertReceiveChannel.id)}
onHide={() => setMaintenanceData(undefined)}
/>
)}
@ -1104,16 +1101,15 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
}
function onIntegrationMigrate() {
alertReceiveChannelStore
.migrateChannel(alertReceiveChannel.id)
AlertReceiveChannelHelper.migrateChannel(alertReceiveChannel.id)
.then(() => {
setConfirmModal(undefined);
openNotification('Integration has been successfully migrated.');
})
.then(() =>
Promise.all([
alertReceiveChannelStore.updateItem(alertReceiveChannel.id),
alertReceiveChannelStore.updateTemplates(alertReceiveChannel.id),
alertReceiveChannelStore.fetchItemById(alertReceiveChannel.id),
alertReceiveChannelStore.fetchTemplates(alertReceiveChannel.id),
])
)
.catch(() => openErrorNotification('An error has occurred. Please try again.'));
@ -1124,8 +1120,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
}
function deleteIntegration() {
alertReceiveChannelStore
.deleteAlertReceiveChannel(alertReceiveChannel.id)
AlertReceiveChannelHelper.deleteAlertReceiveChannel(alertReceiveChannel.id)
.then(() => history.push(`${PLUGIN_ROOT}/integrations`))
.then(() => openNotification('Integration has been succesfully deleted.'))
.catch(() => openErrorNotification('An error has occurred. Please try again.'));
@ -1146,16 +1141,16 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
async function onStopMaintenance() {
setConfirmModal(undefined);
await alertReceiveChannelStore.stopMaintenanceMode(id);
await AlertReceiveChannelHelper.stopMaintenanceMode(id);
openNotification('Maintenance has been stopped');
await alertReceiveChannelStore.updateItem(id);
await alertReceiveChannelStore.fetchItemById(id);
}
};
interface IntegrationHeaderProps {
alertReceiveChannelCounter: AlertReceiveChannelCounters;
alertReceiveChannel: AlertReceiveChannel;
alertReceiveChannel: ApiSchemas['AlertReceiveChannel'];
integration: SelectOption;
renderLabels: boolean;
}
@ -1269,7 +1264,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
);
}
function renderHeartbeat(alertReceiveChannel: AlertReceiveChannel) {
function renderHeartbeat(alertReceiveChannel: ApiSchemas['AlertReceiveChannel']) {
const heartbeatId = alertReceiveChannelStore.alertReceiveChannelToHeartbeat[alertReceiveChannel.id];
const heartbeat = heartbeatStore.items[heartbeatId];

View file

@ -41,11 +41,10 @@ import { TeamName } from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { HeartIcon, HeartRedIcon } from 'icons/Icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import {
AlertReceiveChannel,
MaintenanceMode,
SupportedIntegrationFilters,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { operations } from 'network/oncall-api/autogenerated-api.types';
import { IntegrationHelper } from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
import { PageProps, WithStoreProps } from 'state/types';
@ -79,9 +78,9 @@ const cx = cn.bind(styles);
const FILTERS_DEBOUNCE_MS = 500;
interface IntegrationsState extends PageBaseState {
integrationsFilters: SupportedIntegrationFilters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
alertReceiveChannelIdToShowLabels?: AlertReceiveChannel['id'];
integrationsFilters: operations['alert_receive_channels_list']['parameters']['query'];
alertReceiveChannelId?: ApiSchemas['AlertReceiveChannel']['id'] | 'new';
alertReceiveChannelIdToShowLabels?: ApiSchemas['AlertReceiveChannel']['id'];
confirmationModal: {
isOpen: boolean;
title: any;
@ -103,7 +102,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
super(props);
this.state = {
integrationsFilters: { searchTerm: '', integration_ne: ['direct_paging'] },
integrationsFilters: { integration_ne: ['direct_paging'] },
errorData: initErrorDataState(),
confirmationModal: undefined,
activeTab: props.query[TAB_QUERY_PARAM_KEY] || TabType.MonitoringSystems,
@ -140,12 +139,12 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
return;
}
let alertReceiveChannel: AlertReceiveChannel | void = undefined;
let alertReceiveChannel: ApiSchemas['AlertReceiveChannel'] | void = undefined;
const isNewAlertReceiveChannel = id === 'new';
if (!isNewAlertReceiveChannel) {
alertReceiveChannel = await store.alertReceiveChannelStore
.loadItem(id, true)
.fetchItemById(id, true)
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
}
@ -157,25 +156,25 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
getFiltersBasedOnCurrentTab = () => ({
...this.state.integrationsFilters,
...(this.state.activeTab === TabType.DirectPaging
? { integration: ['direct_paging'] }
? { integration: ['direct_paging' as const] }
: {
integration_ne: ['direct_paging'],
integration_ne: ['direct_paging' as const],
integration: this.state.integrationsFilters.integration?.filter(
(integration) => integration !== 'direct_paging'
),
}),
});
update = () => {
update = async () => {
const { store } = this.props;
const page = store.filtersStore.currentTablePageNum[PAGE.Integrations];
LocationHelper.update({ p: page }, 'partial');
return store.alertReceiveChannelStore.updatePaginatedItems({
await store.alertReceiveChannelStore.fetchPaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page,
updateCounters: false,
shouldFetchCounters: false,
invalidateFn: () => this.invalidateRequestFn(page),
});
};
@ -202,8 +201,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
integrationsFilters,
} = this.state;
const { alertReceiveChannelStore } = store;
const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult();
const { count, results, page_size } = AlertReceiveChannelHelper.getPaginatedSearchResult(alertReceiveChannelStore);
const isDirectPagingSelectedOnMonitoringSystemsTab =
activeTab === TabType.MonitoringSystems && integrationsFilters.integration?.includes('direct_paging');
@ -293,7 +291,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
}}
onSubmit={this.update}
id={alertReceiveChannelId}
navigateToAlertGroupLabels={(id: AlertReceiveChannel['id']) => {
navigateToAlertGroupLabels={(id: ApiSchemas['AlertReceiveChannel']['id']) => {
this.setState({ alertReceiveChannelId: undefined, alertReceiveChannelIdToShowLabels: id });
}}
/>
@ -306,7 +304,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
}}
onSubmit={this.update}
id={alertReceiveChannelIdToShowLabels}
onOpenIntegrationSettings={(id: AlertReceiveChannel['id']) => {
onOpenIntegrationSettings={(id: ApiSchemas['AlertReceiveChannel']['id']) => {
this.setState({ alertReceiveChannelId: id });
}}
/>
@ -333,7 +331,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
}
renderName = (item: AlertReceiveChannel) => {
renderName = (item: ApiSchemas['AlertReceiveChannel']) => {
const { query } = this.props;
return (
@ -353,9 +351,9 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
};
renderDatasource(item: AlertReceiveChannel, alertReceiveChannelStore: AlertReceiveChannelStore) {
renderDatasource(item: ApiSchemas['AlertReceiveChannel'], alertReceiveChannelStore: AlertReceiveChannelStore) {
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
const integration = alertReceiveChannelStore.getIntegration(alertReceiveChannel);
const integration = AlertReceiveChannelHelper.getIntegration(alertReceiveChannelStore, alertReceiveChannel);
const isLegacyIntegration = (integration?.value as string)?.toLowerCase().startsWith('legacy_');
if (isLegacyIntegration) {
@ -379,7 +377,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
}
renderIntegrationStatus(item: AlertReceiveChannel, alertReceiveChannelStore: AlertReceiveChannelStore) {
renderIntegrationStatus(item: ApiSchemas['AlertReceiveChannel'], alertReceiveChannelStore: AlertReceiveChannelStore) {
const alertReceiveChannelCounter = alertReceiveChannelStore.counters[item.id];
let routesCounter = item.routes_count;
let connectedEscalationsChainsCount = item.connected_escalations_chains_count;
@ -426,7 +424,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
}
renderHeartbeat(item: AlertReceiveChannel) {
renderHeartbeat(item: ApiSchemas['AlertReceiveChannel']) {
const { store } = this.props;
const { alertReceiveChannelStore, heartbeatStore } = store;
const alertReceiveChannel = alertReceiveChannelStore.items[item.id];
@ -453,7 +451,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
}
renderMaintenance(item: AlertReceiveChannel) {
renderMaintenance(item: ApiSchemas['AlertReceiveChannel']) {
const maintenanceMode = item.maintenance_mode;
if (maintenanceMode === MaintenanceMode.Debug || maintenanceMode === MaintenanceMode.Maintenance) {
@ -474,7 +472,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
return null;
}
renderTeam(item: AlertReceiveChannel, teams: any) {
renderTeam(item: ApiSchemas['AlertReceiveChannel'], teams: any) {
return (
<TextEllipsisTooltip placement="top" content={teams[item.team]?.name}>
<TeamName className={TEXT_ELLIPSIS_CLASS} team={teams[item.team]} />
@ -482,7 +480,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
);
}
renderButtons = (item: AlertReceiveChannel) => {
renderButtons = (item: ApiSchemas['AlertReceiveChannel']) => {
const { store } = this.props;
return (
@ -573,13 +571,14 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
width: '15%',
title: 'Status',
key: 'status',
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
render: (item: ApiSchemas['AlertReceiveChannel']) =>
this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '25%',
title: 'Type',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
render: (item: ApiSchemas['AlertReceiveChannel']) => this.renderDatasource(item, alertReceiveChannelStore),
},
...(isMonitoringSystemsTab
? [
@ -587,25 +586,25 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
width: '10%',
title: 'Maintenance',
key: 'maintenance',
render: (item: AlertReceiveChannel) => this.renderMaintenance(item),
render: (item: ApiSchemas['AlertReceiveChannel']) => this.renderMaintenance(item),
},
{
width: '5%',
title: 'Heartbeat',
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
render: (item: ApiSchemas['AlertReceiveChannel']) => this.renderHeartbeat(item),
},
]
: []),
{
width: isMonitoringSystemsTab ? '15%' : '30%',
title: 'Team',
render: (item: AlertReceiveChannel) => this.renderTeam(item, grafanaTeamStore.items),
render: (item: ApiSchemas['AlertReceiveChannel']) => this.renderTeam(item, grafanaTeamStore.items),
},
{
width: '50px',
key: 'buttons',
render: (item: AlertReceiveChannel) => this.renderButtons(item),
render: (item: ApiSchemas['AlertReceiveChannel']) => this.renderButtons(item),
className: cx('buttons'),
},
];
@ -614,7 +613,7 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
columns.splice(-2, 0, {
width: '10%',
title: 'Labels',
render: ({ labels }: AlertReceiveChannel) => (
render: ({ labels }: ApiSchemas['AlertReceiveChannel']) => (
<LabelsTooltipBadge labels={labels} onClick={(label) => applyLabelFilter(label, PAGE.Integrations)} />
),
});
@ -636,20 +635,16 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
this.update();
};
onIntegrationEditClick = (id: AlertReceiveChannel['id']) => {
onIntegrationEditClick = (id: ApiSchemas['AlertReceiveChannel']['id']) => {
this.setState({ alertReceiveChannelId: id });
};
onLabelsEditClick = (id: AlertReceiveChannel['id']) => {
onLabelsEditClick = (id: ApiSchemas['AlertReceiveChannel']['id']) => {
this.setState({ alertReceiveChannelIdToShowLabels: id });
};
handleDeleteAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;
alertReceiveChannelStore.deleteAlertReceiveChannel(alertReceiveChannelId).then(this.applyFilters);
handleDeleteAlertReceiveChannel = (alertReceiveChannelId: ApiSchemas['AlertReceiveChannel']['id']) => {
AlertReceiveChannelHelper.deleteAlertReceiveChannel(alertReceiveChannelId).then(this.applyFilters);
this.setState({ confirmationModal: undefined });
};
@ -666,10 +661,10 @@ class _IntegrationsPage extends React.Component<IntegrationsProps, IntegrationsS
const newPage = isOnMount ? store.filtersStore.currentTablePageNum[PAGE.Integrations] : 1;
return alertReceiveChannelStore
.updatePaginatedItems({
.fetchPaginatedItems({
filters: this.getFiltersBasedOnCurrentTab(),
page: newPage,
updateCounters: false,
shouldFetchCounters: false,
invalidateFn: () => this.invalidateRequestFn(newPage),
})
.then(() => {

View file

@ -106,6 +106,7 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
const {
organizationStore: { currentOrganization },
slackStore,
slackChannelStore,
} = store;
return (
@ -119,9 +120,12 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
tooltip="The selected channel will be used as a fallback in the event that a schedule or integration does not have a configured channel"
>
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<GSelect
<GSelect<SlackChannel>
showSearch
modelName="slackChannelStore"
items={slackChannelStore.items}
fetchItemsFn={slackChannelStore.updateItems}
fetchItemFn={slackChannelStore.updateItem}
getSearchResult={slackChannelStore.getSearchResult}
displayField="display_name"
valueField="id"
placeholder="Select Slack Channel"
@ -201,17 +205,22 @@ class _SlackSettings extends Component<SlackProps, SlackState> {
};
renderSlackChannels = () => {
const { store } = this.props;
const {
store: { organizationStore, slackChannelStore },
} = this.props;
return (
<WithPermissionControlTooltip userAction={UserActions.ChatOpsUpdateSettings}>
<GSelect
<GSelect<SlackChannel>
showSearch
className={cx('select', 'control')}
modelName="slackChannelStore"
items={slackChannelStore.items}
fetchItemsFn={slackChannelStore.updateItems}
fetchItemFn={slackChannelStore.updateItem}
getSearchResult={slackChannelStore.getSearchResult}
displayField="display_name"
valueField="id"
placeholder="Select Slack Channel"
value={store.organizationStore.currentOrganization?.slack_channel?.id}
value={organizationStore.currentOrganization?.slack_channel?.id}
onChange={this.handleSlackChannelChange}
nullItemName={PRIVATE_CHANNEL_NAME}
/>

View file

@ -181,7 +181,7 @@ describe('rootBaseStore', () => {
});
isUserActionAllowed.mockReturnValueOnce(true);
PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null);
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -224,7 +224,7 @@ describe('rootBaseStore', () => {
});
isUserActionAllowed.mockReturnValueOnce(true);
PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null);
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -300,7 +300,7 @@ describe('rootBaseStore', () => {
version: 'asdfasdf',
license: 'asdfasdf',
});
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));
@ -329,7 +329,7 @@ describe('rootBaseStore', () => {
license: 'asdfasdf',
});
PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(updatePluginStatusError);
rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser;
Object.defineProperty(rootBaseStore.userStore, 'loadCurrentUser', { value: mockedLoadCurrentUser });
// test
await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl));

View file

@ -5,7 +5,6 @@ import qs from 'query-string';
import { OnCallAppPluginMeta } from 'types';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_filters/alert_receive_channel_filters';
import { AlertGroupStore } from 'models/alertgroup/alertgroup';
import { ApiTokenStore } from 'models/api_token/api_token';
@ -31,6 +30,7 @@ import { TimezoneStore } from 'models/timezone/timezone';
import { UserStore } from 'models/user/user';
import { UserGroupStore } from 'models/user_group/user_group';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { PluginState } from 'state/plugin/plugin';
import { retryFailingPromises } from 'utils/async';
@ -71,7 +71,7 @@ export class RootBaseStore {
initialQuery = qs.parse(window.location.search);
@observable
selectedAlertReceiveChannel?: AlertReceiveChannel['id'];
selectedAlertReceiveChannel?: ApiSchemas['AlertReceiveChannel']['id'];
@observable
features?: { [key: string]: boolean };
@ -141,7 +141,7 @@ export class RootBaseStore {
Promise.all([
this.userStore.updateNotificationPolicyOptions(),
this.userStore.updateNotifyByOptions(),
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
this.alertReceiveChannelStore.fetchAlertReceiveChannelOptions(),
]);
};

View file

@ -12,3 +12,18 @@ export interface TableColumn {
export type PropertiesThatExtendsAnotherClass<OriginalObj, AnotherClass> = keyof {
[Prop in keyof OriginalObj as OriginalObj[Prop] extends AnotherClass ? Prop : never]: unknown;
};
// IfEquals, WritableKeys, ReadonlyKeys based on https://stackoverflow.com/a/49579497/4931398
export type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? A
: B;
export type WritableKeys<T> = {
[P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>;
}[keyof T];
export type ReadonlyKeys<T> = {
[P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>;
}[keyof T];
export type OmitReadonlyMembers<T> = Omit<T, ReadonlyKeys<T>>;

View file

@ -18,18 +18,18 @@ export class KeyValuePair<T = string | number> {
}
}
export const formatBackendError = (payload: string | Record<string, unknown>) =>
typeof payload === 'string'
? payload
: Object.keys(payload)
.map((key) => `${sentenceCase(key)}: ${payload[key]}`)
.join('\n');
export function showApiError(error: any) {
if (isNetworkError(error) && error.response && 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]}`)
.join('\n');
const text = formatBackendError(error.response.data);
openErrorNotification(text);
}
throw error;
}