Merge pull request #4630 from grafana/dev

v1.8.1
This commit is contained in:
Matias Bordese 2024-07-08 13:24:39 -03:00 committed by GitHub
commit 65c905fa06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 241 additions and 323 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -78,6 +78,10 @@ function prepareForSave(rawData: Partial<ApiSchemas['Webhook']>, selectedPreset:
delete data[field];
});
if (data.forward_all) {
data.data = null;
}
return data;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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