commit
65c905fa06
28 changed files with 241 additions and 323 deletions
2
Tiltfile
2
Tiltfile
|
|
@ -46,7 +46,7 @@ docker_build_sub(
|
|||
"localhost:63628/oncall/engine:dev",
|
||||
context="./engine",
|
||||
cache_from=["grafana/oncall:latest", "grafana/oncall:dev"],
|
||||
ignore=["./test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/"],
|
||||
ignore=["./test-results/", "./grafana-plugin/dist/", "./grafana-plugin/e2e-tests/", "./grafana-plugin/node_modules/"],
|
||||
child_context=".",
|
||||
target="dev",
|
||||
extra_cmds=["ADD ./grafana-plugin/src/plugin.json /etc/grafana-plugin/src/plugin.json"],
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ The above command returns JSON structured in the following way:
|
|||
| ----------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `user_id` | Yes | User ID |
|
||||
| `position` | Optional | Personal notification rules execute one after another starting from `position=0`. `Position=-1` will put the escalation policy to the end of the list. A new escalation policy created with a position of an existing escalation policy will move the old one (and all following) down on the list. |
|
||||
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`. |
|
||||
| `type` | Yes | One of: `wait`, `notify_by_slack`, `notify_by_sms`, `notify_by_phone_call`, `notify_by_telegram`, `notify_by_email`, `notify_by_mobile_app`, `notify_by_mobile_app_critical`, or `notify_by_msteams` (**NOTE** `notify_by_msteams` is only available on Grafana Cloud). |
|
||||
| `duration` | Optional | A time in seconds to wait (when `type=wait`). Can be one of 60, 300, 900, 1800, or 3600. |
|
||||
| `important` | Optional | Boolean value indicates if a rule is "important". Default is `false`. |
|
||||
|
||||
|
|
|
|||
|
|
@ -81,10 +81,12 @@ def notify_user_task(
|
|||
|
||||
# Here we collect a brief overview of notification steps configured for user to send it to thread.
|
||||
collected_steps_ids = []
|
||||
for notification_policy in notification_policies:
|
||||
if notification_policy.step == UserNotificationPolicy.Step.NOTIFY:
|
||||
if notification_policy.notify_by not in collected_steps_ids:
|
||||
collected_steps_ids.append(notification_policy.notify_by)
|
||||
for next_notification_policy in notification_policies:
|
||||
if next_notification_policy.step == UserNotificationPolicy.Step.NOTIFY:
|
||||
if next_notification_policy.notify_by not in collected_steps_ids:
|
||||
collected_steps_ids.append(next_notification_policy.notify_by)
|
||||
|
||||
notification_policy = notification_policies[0]
|
||||
|
||||
collected_steps = ", ".join(
|
||||
UserNotificationPolicy.NotificationChannel(step_id).label for step_id in collected_steps_ids
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ class UserView(
|
|||
"send_test_sms": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"export_token": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
|
||||
"upcoming_shifts": [RBACPermission.Permissions.USER_SETTINGS_READ],
|
||||
"filters": [RBACPermission.Permissions.USER_SETTINGS_READ],
|
||||
}
|
||||
|
||||
rbac_object_permissions = {
|
||||
|
|
@ -846,6 +847,46 @@ class UserView(
|
|||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
|
||||
@extend_schema(
|
||||
responses=inline_serializer(
|
||||
name="UserFilters",
|
||||
fields={
|
||||
"name": serializers.CharField(),
|
||||
"type": serializers.CharField(),
|
||||
"href": serializers.CharField(required=False),
|
||||
"global": serializers.BooleanField(required=False),
|
||||
"default": serializers.JSONField(required=False),
|
||||
"description": serializers.CharField(required=False),
|
||||
"options": inline_serializer(
|
||||
name="UserFiltersOptions",
|
||||
fields={
|
||||
"value": serializers.CharField(),
|
||||
"display_name": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
many=True,
|
||||
)
|
||||
)
|
||||
@action(methods=["get"], detail=False)
|
||||
def filters(self, request):
|
||||
filter_name = request.query_params.get("search", None)
|
||||
api_root = "/api/internal/v1/"
|
||||
|
||||
filter_options = [
|
||||
{
|
||||
"name": "team",
|
||||
"type": "team_select",
|
||||
"href": api_root + "teams/",
|
||||
"global": True,
|
||||
},
|
||||
]
|
||||
|
||||
if filter_name is not None:
|
||||
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
|
||||
|
||||
return Response(filter_options)
|
||||
|
||||
|
||||
def handle_phone_notificator_failed(exc: BaseFailed) -> Response:
|
||||
if exc.graceful_msg:
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ def start_sync_org_with_chatops_proxy():
|
|||
organization_qs = Organization.objects.all()
|
||||
organization_pks = organization_qs.values_list("pk", flat=True)
|
||||
|
||||
max_countdown = 60 * 30 # 30 minutes, feel free to adjust
|
||||
max_countdown = 12 * 60 * 60 # 12 hours, feel free to adjust
|
||||
for idx, organization_pk in enumerate(organization_pks):
|
||||
countdown = idx % max_countdown
|
||||
sync_org_with_chatops_proxy.apply_async(kwargs={"org_id": organization_pk}, countdown=countdown)
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@ class InboundEmailWebhookView(AlertChannelDefiningMixin, APIView):
|
|||
# First try envelope_recipient field.
|
||||
# According to AnymailInboundMessage it's provided not by all ESPs.
|
||||
if message.envelope_recipient:
|
||||
token, domain = message.envelope_recipient.split("@")
|
||||
try:
|
||||
token, domain = message.envelope_recipient.split("@")
|
||||
except ValueError:
|
||||
logger.error(
|
||||
f"get_integration_token_from_request: envelope_recipient field has unexpected format: {message.envelope_recipient}"
|
||||
)
|
||||
return None
|
||||
if domain == live_settings.INBOUND_EMAIL_DOMAIN:
|
||||
return token
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -91,8 +91,6 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.chatops_proxy.tasks.unlink_slack_team_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.register_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.unregister_oncall_tenant_async": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.start_sync_org_with_chatops_proxy": {"queue": "default"},
|
||||
"apps.chatops_proxy.tasks.sync_org_with_chatops_proxy": {"queue": "default"},
|
||||
# CRITICAL
|
||||
"apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"},
|
||||
"apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"},
|
||||
|
|
@ -141,6 +139,8 @@ CELERY_TASK_ROUTES = {
|
|||
"apps.alerts.tasks.check_escalation_finished.check_escalation_finished_task": {"queue": "long"},
|
||||
"apps.alerts.tasks.check_escalation_finished.check_alert_group_personal_notifications_task": {"queue": "long"},
|
||||
"apps.alerts.tasks.check_escalation_finished.check_personal_notifications_task": {"queue": "long"},
|
||||
"apps.chatops_proxy.tasks.start_sync_org_with_chatops_proxy": {"queue": "long"},
|
||||
"apps.chatops_proxy.tasks.sync_org_with_chatops_proxy": {"queue": "long"},
|
||||
"apps.grafana_plugin.tasks.sync.cleanup_organization_async": {"queue": "long"},
|
||||
"apps.grafana_plugin.tasks.sync.cleanup_empty_deleted_integrations": {"queue": "long"},
|
||||
"apps.grafana_plugin.tasks.sync.start_cleanup_organizations": {"queue": "long"},
|
||||
|
|
|
|||
|
|
@ -56,9 +56,13 @@ test.describe('Users screen actions', () => {
|
|||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const searchInput = page.locator(`[data-testid="search-users"]`);
|
||||
|
||||
await searchInput.fill(userName);
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Search or filter results\.\.\.$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.keyboard.insertText(userName);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const result = page.locator(`[data-testid="users-username"]`);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface GTableProps<RecordType = unknown> extends TableProps<RecordType
|
|||
expandable?: {
|
||||
expandedRowKeys: string[];
|
||||
expandedRowRender: (item: any) => React.ReactNode;
|
||||
onExpandedRowsChange: (rows: string[]) => void;
|
||||
onExpandedRowsChange?: (rows: string[]) => void;
|
||||
expandRowByClick: boolean;
|
||||
expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode;
|
||||
onExpand?: (expanded: boolean, item: any) => void;
|
||||
|
|
@ -47,7 +47,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
const { expanded, record } = props;
|
||||
return (
|
||||
<Icon
|
||||
style={{ cursor: 'pointer' }}
|
||||
className={styles.expandIcon}
|
||||
name={expanded ? 'angle-down' : 'angle-right'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
|
@ -61,8 +61,8 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
newExpandedRowKeys.splice(index, 1);
|
||||
}
|
||||
|
||||
expandable.onExpand && expandable.onExpand(newExpanded, record);
|
||||
expandable.onExpandedRowsChange(newExpandedRowKeys);
|
||||
expandable.onExpand?.(newExpanded, record);
|
||||
expandable.onExpandedRowsChange?.(newExpandedRowKeys);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -135,7 +135,7 @@ export const GTable = <RT extends DefaultRecordType = DefaultRecordType>(props:
|
|||
}, [rowSelection, columnsProp, data]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, { [styles.fixed]: props.tableLayout === 'fixed' })} data-testid="test__gTable">
|
||||
<div className={styles.root} data-testid="test__gTable">
|
||||
<Table<RT>
|
||||
expandable={expandable}
|
||||
rowKey={rowKey}
|
||||
|
|
@ -161,18 +161,13 @@ const getGTableStyles = () => ({
|
|||
width: 100%;
|
||||
}
|
||||
`,
|
||||
|
||||
fixed: css`
|
||||
table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
`,
|
||||
|
||||
pagination: css`
|
||||
margin-top: 20px;
|
||||
`,
|
||||
|
||||
checkbox: css`
|
||||
display: inline-flex;
|
||||
`,
|
||||
expandIcon: css`
|
||||
cursor: pointer;
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
<Select
|
||||
menuShouldPortal
|
||||
disabled={isDisabled}
|
||||
placeholder="Select Wait Delay"
|
||||
placeholder="Select or type"
|
||||
className={cx(styles.select, styles.control)}
|
||||
value={waitDelayInSeconds ? waitDelayOptionItem : undefined}
|
||||
onChange={(option: SelectableValue) =>
|
||||
|
|
@ -335,7 +335,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
|
|||
<Select
|
||||
menuShouldPortal
|
||||
disabled={isDisabled}
|
||||
placeholder="Period"
|
||||
placeholder="Select or type"
|
||||
className={cx(styles.select, styles.control)}
|
||||
value={num_minutes_in_window ? optionValue : undefined}
|
||||
onChange={this.getOnSelectChangeHandler('num_minutes_in_window')}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,6 @@ export class NotificationPolicy extends React.Component<NotificationPolicyProps,
|
|||
<div className={this.styles.container}>
|
||||
<Select
|
||||
key="wait-delay"
|
||||
placeholder="Wait Delay"
|
||||
className={cx(this.styles.delay, this.styles.control)}
|
||||
value={wait_delay ? optionValue : undefined}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export const POLICY_DURATION_LIST_MINUTES: SelectableValue[] = [...POLICY_DURATI
|
|||
export const CUSTOM_SILENCE_VALUE = -100;
|
||||
|
||||
export const SILENCE_DURATION_LIST: SelectableValue[] = [
|
||||
{ value: CUSTOM_SILENCE_VALUE, label: 'Custom' },
|
||||
{ value: 30 * 60, label: '30 minutes' },
|
||||
{ value: 1 * 60 * 60, label: '1 hour' },
|
||||
{ value: 2 * 60 * 60, label: '2 hours' },
|
||||
|
|
@ -43,4 +42,5 @@ export const SILENCE_DURATION_LIST: SelectableValue[] = [
|
|||
{ value: 12 * 60 * 60, label: '12 hours' },
|
||||
{ value: 24 * 60 * 60, label: '24 hours' },
|
||||
{ value: -1, label: 'Forever' },
|
||||
{ value: CUSTOM_SILENCE_VALUE, label: 'Custom' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getTableStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
root: css`
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table :global(.rc-table-row-expand-icon-cell) > span {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
td {
|
||||
min-height: 60px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
`,
|
||||
|
||||
pagination: css`
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
`,
|
||||
|
||||
expandIcon: css`
|
||||
padding: 10px;
|
||||
color: ${theme.colors.text.primary};
|
||||
pointer-events: none;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&--expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
`,
|
||||
|
||||
rowEven: css`
|
||||
background: ${theme.colors.background.secondary};
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { cx } from '@emotion/css';
|
||||
import { Pagination, VerticalGroup, useStyles2 } from '@grafana/ui';
|
||||
import Table from 'rc-table';
|
||||
import { TableProps } from 'rc-table/lib/Table';
|
||||
import { bem } from 'styles/utils.styles';
|
||||
|
||||
import { ExpandIcon } from 'icons/Icons';
|
||||
|
||||
import { getTableStyles } from './Table.styles';
|
||||
|
||||
export interface Props<RecordType = unknown> extends TableProps<RecordType> {
|
||||
loading?: boolean;
|
||||
pagination?: {
|
||||
page: number;
|
||||
total: number;
|
||||
onChange: (page: number) => void;
|
||||
};
|
||||
rowSelection?: {
|
||||
selectedRowKeys: string[];
|
||||
onChange: (selectedRowKeys: string[]) => void;
|
||||
};
|
||||
expandable?: {
|
||||
expandedRowKeys: string[];
|
||||
expandedRowRender: (item: any) => React.ReactNode;
|
||||
onExpandedRowsChange?: (rows: string[]) => void;
|
||||
expandRowByClick: boolean;
|
||||
expandIcon?: (props: { expanded: boolean; record: any }) => React.ReactNode;
|
||||
onExpand?: (expanded: boolean, item: any) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const GTable: FC<Props> = (props) => {
|
||||
const { columns, data, className, pagination, loading, rowKey, expandable, ...restProps } = props;
|
||||
const { page, total: numberOfPages, onChange: onNavigate } = pagination || {};
|
||||
|
||||
const styles = useStyles2(getTableStyles);
|
||||
|
||||
const expandableFn = useMemo(() => {
|
||||
return expandable
|
||||
? {
|
||||
...expandable,
|
||||
expandIcon: ({ expanded }) => {
|
||||
return (
|
||||
<div className={cx(styles.expandIcon, { [bem(styles.expandIcon, 'expanded')]: expanded })}>
|
||||
<ExpandIcon />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
expandedRowClassName: (_record, index) => (index % 2 === 0 ? styles.rowEven : ''),
|
||||
}
|
||||
: null;
|
||||
}, [expandable]);
|
||||
|
||||
return (
|
||||
<VerticalGroup justify="flex-end">
|
||||
<Table
|
||||
rowKey={rowKey}
|
||||
className={cx(styles.root, className)}
|
||||
columns={columns}
|
||||
data={data}
|
||||
expandable={expandableFn}
|
||||
rowClassName={(_record, index) => (index % 2 === 0 ? styles.rowEven : '')}
|
||||
{...restProps}
|
||||
/>
|
||||
{pagination && (
|
||||
<div className={styles.pagination}>
|
||||
<Pagination hideWhenSinglePage currentPage={page} numberOfPages={numberOfPages} onNavigate={onNavigate} />
|
||||
</div>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -78,6 +78,10 @@ function prepareForSave(rawData: Partial<ApiSchemas['Webhook']>, selectedPreset:
|
|||
delete data[field];
|
||||
});
|
||||
|
||||
if (data.forward_all) {
|
||||
data.data = null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Button, Field, Input, Select, Switch, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Field, Input, RadioButtonList, Select, Switch, useStyles2 } from '@grafana/ui';
|
||||
import Emoji from 'react-emoji-render';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { GSelect } from 'containers/GSelect/GSelect';
|
||||
import { Labels } from 'containers/Labels/Labels';
|
||||
import { AlertReceiveChannelHelper } from 'models/alert_receive_channel/alert_receive_channel.helpers';
|
||||
|
|
@ -29,6 +30,21 @@ interface OutgoingWebhookFormFieldsProps {
|
|||
onTemplateEditClick: (params: TemplateParams) => void;
|
||||
}
|
||||
|
||||
const FORWARD = 'forward';
|
||||
const CUSTOMIZE = 'customise';
|
||||
const FORWARD_RADIO_OPTIONS = [
|
||||
{
|
||||
label: 'Forward whole payload data',
|
||||
value: FORWARD,
|
||||
boolean: true,
|
||||
},
|
||||
{
|
||||
label: 'Customise forwarded data',
|
||||
value: CUSTOMIZE,
|
||||
boolean: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const OutgoingWebhookFormFields = ({
|
||||
preset,
|
||||
hasLabelsFeature,
|
||||
|
|
@ -42,7 +58,6 @@ export const OutgoingWebhookFormFields = ({
|
|||
} = useFormContext<ApiSchemas['Webhook']>();
|
||||
|
||||
const forwardAll = watch(WebhookFormFieldName.ForwardAll);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const controls = (
|
||||
|
|
@ -175,6 +190,7 @@ export const OutgoingWebhookFormFields = ({
|
|||
/>
|
||||
)}
|
||||
<Controller
|
||||
rules={{ required: 'Webhook URL is required' }}
|
||||
name={WebhookFormFieldName.Url}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
|
|
@ -299,55 +315,67 @@ export const OutgoingWebhookFormFields = ({
|
|||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.ForwardAll}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Forward All"
|
||||
description="Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data"
|
||||
invalid={Boolean(errors.forward_all)}
|
||||
error={errors.forward_all?.message}
|
||||
>
|
||||
<Switch value={field.value} onChange={field.onChange} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Data}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Data"
|
||||
description={`Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={{ ...MONACO_EDITABLE_CONFIG, readOnly: forwardAll }}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook Data',
|
||||
})
|
||||
}
|
||||
|
||||
<RenderConditionally shouldRender={!preset?.controlled_fields.includes(WebhookFormFieldName.ForwardAll)}>
|
||||
<Field>
|
||||
<Controller
|
||||
name={WebhookFormFieldName.ForwardAll}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RadioButtonList
|
||||
name="forwardData"
|
||||
options={FORWARD_RADIO_OPTIONS}
|
||||
value={FORWARD_RADIO_OPTIONS.find((opt) => opt.boolean === field.value)?.value}
|
||||
onChange={(value) => field.onChange(value === FORWARD)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<RenderConditionally
|
||||
shouldRender={!forwardAll}
|
||||
render={() => (
|
||||
<Controller
|
||||
name={WebhookFormFieldName.Data}
|
||||
rules={{ required: 'Data is required' }}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
label="Data"
|
||||
invalid={Boolean(errors.data)}
|
||||
error={errors.data?.message}
|
||||
description={`Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<MonacoEditor
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={{ ...MONACO_EDITABLE_CONFIG }}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onTemplateEditClick({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
displayName: 'Webhook Data',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</RenderConditionally>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export const ServiceNowTokenSection: React.FC<ServiceNowTokenSectionProps> = obs
|
|||
|
||||
function renderGenerateButton() {
|
||||
return (
|
||||
<Button variant="secondary" onClick={onTokenGenerate} disabled={isLoading} className={'aaaa'}>
|
||||
<Button variant="secondary" onClick={onTokenGenerate} disabled={isLoading}>
|
||||
{isExistingToken ? 'Regenerate' : 'Generate'}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ export class UserHelper {
|
|||
* NOTE: if is_currently_oncall=all the backend will not paginate the results, it will send back an array of ALL users
|
||||
*/
|
||||
static async search(f: any = { searchTerm: '' }, page = 1) {
|
||||
const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility
|
||||
const { searchTerm: search, ...restFilters } = filters;
|
||||
return (await onCallApi().GET('/users/', { params: { query: { search, page, ...restFilters } } })).data;
|
||||
const filters = typeof f === 'string' ? { search: f } : f; // for GSelect compatibility
|
||||
return (await onCallApi().GET('/users/', { params: { query: { ...filters, page } } })).data;
|
||||
}
|
||||
|
||||
static getSearchResult(userStore: UserStore) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import dayjs from 'dayjs';
|
|||
import { get } from 'lodash-es';
|
||||
import { action, computed, runInAction, makeAutoObservable } from 'mobx';
|
||||
|
||||
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { NotificationPolicyType } from 'models/notification_policy/notification_policy';
|
||||
import { makeRequest } from 'network/network';
|
||||
|
|
@ -36,7 +37,11 @@ export class UserStore {
|
|||
this.rootStore = rootStore;
|
||||
}
|
||||
|
||||
async fetchItems(f: any = { searchTerm: '' }, page = 1, invalidateFn?: () => boolean): Promise<any> {
|
||||
async fetchItems(
|
||||
f: RemoteFiltersType | string = { searchTerm: '', type: undefined, used: undefined },
|
||||
page = 1,
|
||||
invalidateFn?: () => boolean
|
||||
): Promise<any> {
|
||||
const response = await UserHelper.search(f, page);
|
||||
|
||||
if (invalidateFn && invalidateFn()) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { TextEllipsisTooltip } from 'components/TextEllipsisTooltip/TextEllipsis
|
|||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { ApiSchemas } from 'network/oncall-api/api.types';
|
||||
import { SilenceButtonCascader } from 'pages/incidents/parts/SilenceButtonCascader';
|
||||
import { SilenceSelect } from 'pages/incidents/parts/SilenceSelect';
|
||||
import { move } from 'state/helpers';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ export function getActionButtons(
|
|||
|
||||
const silenceButton = (
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<SilenceButtonCascader disabled={incident?.loading} onSelect={onSilence} />
|
||||
<SilenceSelect disabled={incident?.loading} onSelect={onSilence} />
|
||||
</WithPermissionControlTooltip>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import { getItem, setItem } from 'utils/localStorage';
|
|||
import { TableColumn } from 'utils/types';
|
||||
|
||||
import { IncidentDropdown } from './parts/IncidentDropdown';
|
||||
import { SilenceButtonCascader } from './parts/SilenceButtonCascader';
|
||||
import { SilenceSelect } from './parts/SilenceSelect';
|
||||
|
||||
interface Pagination {
|
||||
start: number;
|
||||
|
|
@ -487,7 +487,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
)}
|
||||
{'restart' in store.alertGroupStore.bulkActions && (
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<SilenceButtonCascader
|
||||
<SilenceSelect
|
||||
disabled={!hasSelected || isBulkUpdate}
|
||||
onSelect={(ev) => this.onBulkActionClick('silence', ev)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ButtonCascader, CascaderOption, ComponentSize } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { SILENCE_DURATION_LIST } from 'components/Policy/Policy.consts';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { UserActions } from 'utils/authorization/authorization';
|
||||
|
||||
interface SilenceButtonCascaderProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
buttonSize?: string;
|
||||
|
||||
onSelect: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SilenceButtonCascader = observer((props: SilenceButtonCascaderProps) => {
|
||||
const { onSelect, className, disabled = false, buttonSize } = props;
|
||||
|
||||
return (
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<ButtonCascader
|
||||
variant="secondary"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onSelect(Number(value))}
|
||||
options={getOptions()}
|
||||
value={undefined}
|
||||
buttonProps={{ size: buttonSize as ComponentSize }}
|
||||
>
|
||||
Silence
|
||||
</ButtonCascader>
|
||||
</WithPermissionControlTooltip>
|
||||
);
|
||||
|
||||
function getOptions(): CascaderOption[] {
|
||||
return [...SILENCE_DURATION_LIST] as CascaderOption[];
|
||||
}
|
||||
});
|
||||
|
|
@ -9,18 +9,20 @@ import { UserActions } from 'utils/authorization/authorization';
|
|||
|
||||
interface SilenceSelectProps {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
onSelect: (value: number) => void;
|
||||
}
|
||||
|
||||
export const SilenceSelect = observer((props: SilenceSelectProps) => {
|
||||
const { placeholder = 'Silence for', onSelect } = props;
|
||||
const { placeholder = 'Silence for', disabled = false, onSelect } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{' '}
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
menuShouldPortal
|
||||
placeholder={placeholder}
|
||||
value={undefined}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ export const getSchedulesStyles = () => {
|
|||
position: relative;
|
||||
`,
|
||||
|
||||
tableRoot: css`
|
||||
td.rc-table-row-expand-icon-cell {
|
||||
position: relative;
|
||||
left: 3px;
|
||||
}
|
||||
`,
|
||||
|
||||
table: css`
|
||||
td {
|
||||
padding-top: 5px;
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
|
|||
import { getUtilStyles } from 'styles/utils.styles';
|
||||
|
||||
import { Avatar } from 'components/Avatar/Avatar';
|
||||
import { GTable, GTableProps } from 'components/GTable/GTable';
|
||||
import { NewScheduleSelector } from 'components/NewScheduleSelector/NewScheduleSelector';
|
||||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { GTable } from 'components/Table/Table';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { TextEllipsisTooltip } from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
|
||||
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
|
||||
|
|
@ -62,7 +62,6 @@ class _SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSt
|
|||
const {
|
||||
store: { userStore },
|
||||
} = this.props;
|
||||
|
||||
userStore.fetchItems();
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ class _SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSt
|
|||
onChange={this.handleSchedulesFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="schedules-table">
|
||||
<div className={cx(styles.tableRoot)} data-testid="schedules-table">
|
||||
<GTable
|
||||
className={styles.table}
|
||||
columns={this.getTableColumns()}
|
||||
|
|
@ -115,10 +114,9 @@ class _SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSt
|
|||
total: results ? Math.ceil((count || 0) / page_size) : 0,
|
||||
onChange: this.handlePageChange,
|
||||
}}
|
||||
tableLayout="fixed"
|
||||
rowKey="id"
|
||||
expandable={{
|
||||
expandedRowKeys: expandedRowKeys,
|
||||
expandedRowKeys,
|
||||
onExpand: this.handleExpandRow,
|
||||
expandedRowRender: this.renderSchedule,
|
||||
expandRowByClick: true,
|
||||
|
|
@ -282,7 +280,7 @@ class _SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSt
|
|||
return <PluginLink query={{ page: 'schedules', id: item.id, ...query }}>{item.name}</PluginLink>;
|
||||
};
|
||||
|
||||
renderOncallNow = (item: Schedule, _index: number) => {
|
||||
renderOncallNow = (item: Schedule) => {
|
||||
const { theme } = this.props;
|
||||
const utilsStyles = getUtilStyles(theme);
|
||||
|
||||
|
|
@ -407,11 +405,17 @@ class _SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSt
|
|||
};
|
||||
};
|
||||
|
||||
getTableColumns = () => {
|
||||
getTableColumns = (): GTableProps<Schedule>['columns'] => {
|
||||
const { grafanaTeamStore } = this.props.store;
|
||||
const styles = getSchedulesStyles();
|
||||
|
||||
return [
|
||||
{
|
||||
// Allow space for icon (>)
|
||||
width: '40px',
|
||||
title: '',
|
||||
render: () => <></>,
|
||||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Type',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||
|
||||
export const getUsersStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
filters: css`
|
||||
margin-bottom: 20px;
|
||||
`,
|
||||
|
||||
usersTtitle: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import {
|
|||
import { PluginLink } from 'components/PluginLink/PluginLink';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { TooltipBadge } from 'components/TooltipBadge/TooltipBadge';
|
||||
import { UsersFilters } from 'components/UsersFilters/UsersFilters';
|
||||
import { RemoteFilters } from 'containers/RemoteFilters/RemoteFilters';
|
||||
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
|
||||
import { UserSettings } from 'containers/UserSettings/UserSettings';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { UserHelper } from 'models/user/user.helpers';
|
||||
|
|
@ -44,9 +45,8 @@ const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
|
|||
interface UsersState extends PageBaseState {
|
||||
isWrongTeam: boolean;
|
||||
userPkToEdit?: ApiSchemas['User']['pk'] | 'new';
|
||||
usersFilters?: {
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
filters: RemoteFiltersType;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -62,9 +62,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
this.state = {
|
||||
isWrongTeam: false,
|
||||
userPkToEdit: undefined,
|
||||
usersFilters: {
|
||||
searchTerm: '',
|
||||
},
|
||||
filters: { searchTerm: '', type: undefined, used: undefined, mine: undefined },
|
||||
|
||||
errorData: initErrorDataState(),
|
||||
};
|
||||
|
|
@ -80,7 +78,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
|
||||
updateUsers = debounce(async (invalidateFn?: () => boolean) => {
|
||||
const { store } = this.props;
|
||||
const { usersFilters } = this.state;
|
||||
const { filters } = this.state;
|
||||
const { userStore, filtersStore } = store;
|
||||
const page = filtersStore.currentTablePageNum[PAGE.Users];
|
||||
|
||||
|
|
@ -89,7 +87,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
}
|
||||
|
||||
LocationHelper.update({ p: page }, 'partial');
|
||||
await userStore.fetchItems(usersFilters, page, invalidateFn);
|
||||
await userStore.fetchItems(filters, page, invalidateFn);
|
||||
|
||||
this.forceUpdate();
|
||||
}, DEBOUNCE_MS);
|
||||
|
|
@ -184,38 +182,20 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
renderContentIfAuthorized(authorizedToViewUsers: boolean) {
|
||||
const {
|
||||
store: { userStore, filtersStore },
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const { usersFilters, userPkToEdit } = this.state;
|
||||
const { userPkToEdit } = this.state;
|
||||
|
||||
const page = filtersStore.currentTablePageNum[PAGE.Users];
|
||||
|
||||
const { count, results, page_size } = UserHelper.getSearchResult(userStore);
|
||||
const columns = this.getTableColumns();
|
||||
|
||||
const handleClear = () =>
|
||||
this.setState({ usersFilters: { searchTerm: '' } }, () => {
|
||||
this.updateUsers();
|
||||
});
|
||||
const styles = getUsersStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
{authorizedToViewUsers ? (
|
||||
<>
|
||||
<div className={styles.userFiltersContainer} data-testid="users-filters">
|
||||
<UsersFilters
|
||||
className={styles.usersFilters}
|
||||
value={usersFilters}
|
||||
isLoading={results === undefined}
|
||||
onChange={this.handleUsersFiltersChange}
|
||||
/>
|
||||
<Button variant="secondary" icon="times" onClick={handleClear}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{this.renderFilters()}
|
||||
<GTable
|
||||
data-testid="users-table"
|
||||
emptyText={results ? 'No users found' : 'Loading...'}
|
||||
|
|
@ -250,6 +230,33 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
);
|
||||
}
|
||||
|
||||
renderFilters() {
|
||||
const { query, store, theme } = this.props;
|
||||
const styles = getUsersStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
<RemoteFilters
|
||||
query={query}
|
||||
page={PAGE.Users}
|
||||
grafanaTeamStore={store.grafanaTeamStore}
|
||||
onChange={this.handleFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleFiltersChange = (filters: RemoteFiltersType, _isOnMount: boolean) => {
|
||||
const { filtersStore } = this.props.store;
|
||||
const currentTablePage = filtersStore.currentTablePageNum[PAGE.Users];
|
||||
|
||||
LocationHelper.update({ p: currentTablePage }, 'partial');
|
||||
|
||||
this.setState({ filters }, () => {
|
||||
this.updateUsers();
|
||||
});
|
||||
};
|
||||
|
||||
renderTitle = (user: ApiSchemas['User']) => {
|
||||
const {
|
||||
store: { userStore },
|
||||
|
|
@ -288,18 +295,6 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
return user.notification_chain_verbal.important;
|
||||
};
|
||||
|
||||
renderContacts = (user: ApiSchemas['User']) => {
|
||||
const { store } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div>Slack: {user.slack_user_identity?.name || '-'}</div>
|
||||
{store.hasFeature(AppFeature.Telegram) && (
|
||||
<div>Telegram: {user.telegram_configuration?.telegram_nick_name || '-'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
renderButtons = (user: ApiSchemas['User']) => {
|
||||
const { store } = this.props;
|
||||
const { userStore } = store;
|
||||
|
|
@ -442,16 +437,6 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
this.updateUsers();
|
||||
};
|
||||
|
||||
handleUsersFiltersChange = (usersFilters: any, invalidateFn: () => boolean) => {
|
||||
const { filtersStore } = this.props.store;
|
||||
|
||||
filtersStore.currentTablePageNum[PAGE.Users] = 1;
|
||||
|
||||
this.setState({ usersFilters }, () => {
|
||||
this.updateUsers(invalidateFn);
|
||||
});
|
||||
};
|
||||
|
||||
handleHideUserSettings = () => {
|
||||
const { history } = this.props;
|
||||
this.setState({ userPkToEdit: undefined });
|
||||
|
|
|
|||
|
|
@ -35,23 +35,24 @@ export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall'
|
|||
export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall';
|
||||
export const ONCALL_DEV = 'https://oncall-dev-us-central-0.grafana.net/oncall';
|
||||
|
||||
export const getProcessEnvVarSafely = (name: string) => {
|
||||
export const getIsDevelopmentEnv = () => {
|
||||
try {
|
||||
return process.env[name];
|
||||
return process.env.NODE_ENV === 'development';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getIsDevelopmentEnv = () => getProcessEnvVarSafely['NODE_ENV'] === 'development';
|
||||
|
||||
// Single source of truth on the frontend for OnCall API URL
|
||||
export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) => {
|
||||
if (meta?.jsonData?.onCallApiUrl) {
|
||||
return meta?.jsonData?.onCallApiUrl;
|
||||
} else if (typeof window === 'undefined') {
|
||||
return getProcessEnvVarSafely('ONCALL_API_URL');
|
||||
try {
|
||||
return process.env.ONCALL_API_URL;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue