diff --git a/Tiltfile b/Tiltfile index ebb036dc..a2cd3a51 100644 --- a/Tiltfile +++ b/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"], diff --git a/docs/sources/oncall-api-reference/personal_notification_rules.md b/docs/sources/oncall-api-reference/personal_notification_rules.md index 422749b2..e683e90b 100644 --- a/docs/sources/oncall-api-reference/personal_notification_rules.md +++ b/docs/sources/oncall-api-reference/personal_notification_rules.md @@ -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`. | diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 2a197338..1f7b7c1c 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -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 diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 940e08a6..85b81825 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -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: diff --git a/engine/apps/chatops_proxy/tasks.py b/engine/apps/chatops_proxy/tasks.py index 785233e3..0e93500d 100644 --- a/engine/apps/chatops_proxy/tasks.py +++ b/engine/apps/chatops_proxy/tasks.py @@ -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) diff --git a/engine/apps/email/inbound.py b/engine/apps/email/inbound.py index 5a37daa3..76eec2aa 100644 --- a/engine/apps/email/inbound.py +++ b/engine/apps/email/inbound.py @@ -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: diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 01d4a155..9d8e0537 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -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"}, diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index 3134f262..f106abd9 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -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"]`); diff --git a/grafana-plugin/src/components/GTable/GTable.tsx b/grafana-plugin/src/components/GTable/GTable.tsx index b251a4ee..65a21601 100644 --- a/grafana-plugin/src/components/GTable/GTable.tsx +++ b/grafana-plugin/src/components/GTable/GTable.tsx @@ -19,7 +19,7 @@ export interface GTableProps extends TableProps 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 = (props: const { expanded, record } = props; return ( { event.stopPropagation(); @@ -61,8 +61,8 @@ export const GTable = (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 = (props: }, [rowSelection, columnsProp, data]); return ( -
+
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; + `, }); diff --git a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx index 95212384..c91700e9 100644 --- a/grafana-plugin/src/components/Policy/EscalationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/EscalationPolicy.tsx @@ -279,7 +279,7 @@ class _EscalationPolicy extends React.Component { { position: relative; `, + tableRoot: css` + td.rc-table-row-expand-icon-cell { + position: relative; + left: 3px; + } + `, + table: css` td { padding-top: 5px; diff --git a/grafana-plugin/src/pages/schedules/Schedules.tsx b/grafana-plugin/src/pages/schedules/Schedules.tsx index de49ee6d..390abaa7 100644 --- a/grafana-plugin/src/pages/schedules/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules/Schedules.tsx @@ -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
-
+
{item.name}; }; - 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 { + getTableColumns = (): GTableProps['columns'] => { const { grafanaTeamStore } = this.props.store; const styles = getSchedulesStyles(); return [ + { + // Allow space for icon (>) + width: '40px', + title: '', + render: () => <>, + }, { width: '10%', title: 'Type', diff --git a/grafana-plugin/src/pages/users/Users.styles.ts b/grafana-plugin/src/pages/users/Users.styles.ts index bd439484..b8b48c22 100644 --- a/grafana-plugin/src/pages/users/Users.styles.ts +++ b/grafana-plugin/src/pages/users/Users.styles.ts @@ -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; diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index ed9c9817..cf18d789 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -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 { 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 { 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 { } 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 { 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 ? ( <> -
- - -
- + {this.renderFilters()} { ); } + renderFilters() { + const { query, store, theme } = this.props; + const styles = getUsersStyles(theme); + + return ( +
+ +
+ ); + } + + 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 { return user.notification_chain_verbal.important; }; - renderContacts = (user: ApiSchemas['User']) => { - const { store } = this.props; - return ( -
-
Slack: {user.slack_user_identity?.name || '-'}
- {store.hasFeature(AppFeature.Telegram) && ( -
Telegram: {user.telegram_configuration?.telegram_nick_name || '-'}
- )} -
- ); - }; - renderButtons = (user: ApiSchemas['User']) => { const { store } = this.props; const { userStore } = store; @@ -442,16 +437,6 @@ class Users extends React.Component { 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 }); diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index 7e26bae2..a091378e 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -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; };