diff --git a/client/src/components/main/notification/NotificationBrowser.css b/client/src/components/main/notification/NotificationBrowser.css index ca6e5ae53d..d4046fefd6 100644 --- a/client/src/components/main/notification/NotificationBrowser.css +++ b/client/src/components/main/notification/NotificationBrowser.css @@ -50,7 +50,7 @@ .notification-grid-row { display: grid; - grid: "status title body date readDate" 34px / 55px 1fr 1fr 135px 135px; + grid: "status title body date readDate actions" 34px / 55px 1fr 1fr 135px 135px 25px; padding: 3px 0; cursor: pointer; transition: all 0.3s ease; @@ -97,6 +97,10 @@ grid-area: readDate; } +.notification-actions { + grid-area: actions; +} + .empty-placeholder { padding: 15px; text-align: center; diff --git a/client/src/components/main/notification/NotificationBrowser.js b/client/src/components/main/notification/NotificationBrowser.js index 3bb7989f2a..25eeb27b9c 100644 --- a/client/src/components/main/notification/NotificationBrowser.js +++ b/client/src/components/main/notification/NotificationBrowser.js @@ -33,6 +33,7 @@ import NotificationsRequest from '../../../models/notifications/CurrentUserNotif import ReadAllUserNotifications from '../../../models/notifications/ReadAllUserNotifications'; import displayDate from '../../../utils/displayDate'; import PreviewNotification from './PreviewNotification'; +import NotificationActions from './notification-actions'; import styles from './NotificationBrowser.css'; const PAGE_SIZE = 20; @@ -54,7 +55,7 @@ function dateSorter (a, b) { } }; -@inject('userNotifications') +@inject('userNotifications', 'router') @observer export default class NotificationBrowser extends React.Component { state = { @@ -214,6 +215,11 @@ export default class NotificationBrowser extends React.Component { )}> Read date +
); }; @@ -288,6 +294,18 @@ export default class NotificationBrowser extends React.Component { )}> {displayDate(notification.readDate, 'YYYY-MM-DD HH:mm:ss')} +
e.stopPropagation()} + > + +
)) : emptyPlaceholder diff --git a/client/src/components/main/notification/NotificationCenter.js b/client/src/components/main/notification/NotificationCenter.js index bb217e37b5..0f81f03066 100644 --- a/client/src/components/main/notification/NotificationCenter.js +++ b/client/src/components/main/notification/NotificationCenter.js @@ -61,7 +61,9 @@ function mapMessage (message) { isRead: message.isRead, userId: message.userId, notificationId: `message_${message.id}`, - type: NOTIFICATION_TYPE.message + notificationType: NOTIFICATION_TYPE.message, + resources: message.resources, + type: message.type }; } @@ -309,7 +311,7 @@ export default class NotificationCenter extends React.Component { id: notification.notificationId, createdDate: notification.createdDate }); - if (notification.type === NOTIFICATION_TYPE.message) { + if (notification.notificationType === NOTIFICATION_TYPE.message) { this.readMessage(notification); } if (notification.blocking) { @@ -459,7 +461,7 @@ export default class NotificationCenter extends React.Component { openPreviewNotification = (notification) => { this.setState({previewNotification: notification}, () => { - if (notification.type === NOTIFICATION_TYPE.message) { + if (notification.notificationType === NOTIFICATION_TYPE.message) { this.readMessage(notification, true); } }); @@ -489,8 +491,8 @@ export default class NotificationCenter extends React.Component { onHeightInitialized={this.onHeightInitialized} key={notification.notificationId || notification.createdDate} notification={notification} - type={notification.type} - onClick={notification.type === NOTIFICATION_TYPE.message + type={notification.notificationType} + onClick={notification.notificationType === NOTIFICATION_TYPE.message ? this.openPreviewNotification : undefined } diff --git a/client/src/components/main/notification/SystemNotification.js b/client/src/components/main/notification/SystemNotification.js index 160d694915..fd50e2348b 100644 --- a/client/src/components/main/notification/SystemNotification.js +++ b/client/src/components/main/notification/SystemNotification.js @@ -22,6 +22,7 @@ import classNames from 'classnames'; import displayDate from '../../../utils/displayDate'; import PreviewNotification from './PreviewNotification'; import {NOTIFICATION_TYPE} from './NotificationCenter'; +import NotificationActions from './notification-actions'; import styles from './SystemNotification.css'; @observer @@ -200,6 +201,11 @@ export default class SystemNotification extends React.Component { > {displayDate(this.props.notification.createdDate)} + diff --git a/client/src/components/main/notification/notification-actions/actions.js b/client/src/components/main/notification/notification-actions/actions.js new file mode 100644 index 0000000000..f4e1f78e47 --- /dev/null +++ b/client/src/components/main/notification/notification-actions/actions.js @@ -0,0 +1,171 @@ +/* + * Copyright 2017-2023 EPAM Systems, Inc. (https://www.epam.com/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {message} from 'antd'; +import moment from 'moment-timezone'; +import StopPipeline from '../../../../models/pipelines/StopPipeline'; +import ResumePipeline from '../../../../models/pipelines/ResumePipeline'; +import PausePipeline from '../../../../models/pipelines/PausePipeline'; +import TerminatePipeline from '../../../../models/pipelines/TerminatePipeline'; +import DataStorageLifeCycleRulesPostpone +from '../../../../models/dataStorage/lifeCycleRules/DataStorageLifeCycleRulesPostpone'; +import {canPauseRun, canStopRun} from '../../../runs/actions'; +import RunStatuses from '../../../special/run-status-icon/run-statuses'; + +const ACTIONS = { + viewRun: { + key: 'View run', + actionFn: ({entity, router}) => { + router && router.push(`/run/${entity.id}`); + }, + available: () => true + }, + pauseRun: { + key: 'Pause run', + actionFn: async ({entity, callback}) => { + const hide = message.loading('Pausing...', -1); + const request = new PausePipeline(entity.id); + await request.send({}); + if (request.error) { + message.error(request.error); + } + hide(); + callback && callback(); + }, + available: (entity, preferences) => entity && preferences && canPauseRun(entity, preferences) + }, + resumeRun: { + key: 'Resume run', + actionFn: async ({entity, callback}) => { + const hide = message.loading('Resuming...', -1); + const request = new ResumePipeline(entity.id); + await request.send({}); + if (request.error) { + message.error(request.error); + } + hide(); + callback && callback(); + }, + available: (entity) => entity && entity.status === RunStatuses.paused + }, + stopRun: { + key: 'Stop run', + actionFn: async ({entity, callback}) => { + const hide = message.loading('Stopping...', -1); + const request = new StopPipeline(entity.id); + await request.send({ + endDate: moment().format('YYYY-MM-DD HH:mm:ss.SSS'), + status: 'STOPPED' + }); + if (request.error) { + message.error(request.error); + } + hide(); + callback && callback(); + }, + available: (entity) => entity && canStopRun(entity) + }, + terminateRun: { + key: 'Terminate run', + actionFn: async ({entity, callback}) => { + const hide = message.loading('Terminating run...', -1); + const request = new TerminatePipeline(entity.id); + await request.send({}); + if (request.error) { + message.error(request.error); + } + hide(); + callback && callback(); + }, + available: (entity) => entity && entity.status === RunStatuses.paused + }, + openDatastorage: { + key: 'Open datastorage', + actionFn: ({notification = {}, router}) => { + const details = (notification.resources || [])[0] || {}; + const {entityId} = details; + router && entityId && router.push(`/storage/${entityId}`); + }, + available: () => true + }, + postponeLifecycleRule: { + key: 'Postpone', + actionFn: async ({notification = {}, callback}) => { + const details = (notification.resources || [])[0] || {}; + const hide = message.loading('Postpone...', -1); + const request = new DataStorageLifeCycleRulesPostpone({ + datastorageId: details.entityId, + ruleId: details.storageRuleId, + path: details.storagePath + }); + await request.fetch(); + if (request.error) { + message.error(request.error); + } + hide(); + callback && callback(); + }, + available: () => true + }, + viewBilling: { + key: 'View billing', + actionFn: ({router}) => { + router && router.push('/billing/reports/storage'); + }, + available: () => true + }, + openPoolsUsage: { + key: 'Open pools usage statistics', + actionFn: ({router}) => { + router && router.push('/cluster/usage'); + }, + available: () => true + } +}; + +const ENTITY_CLASSES = { + RUN: 'RUN', + STORAGE: 'STORAGE', + ISSUE: 'ISSUE', + QUOTA: 'QUOTA', + NODE_POOL: 'NODE_POOL', + USER: 'USER' +}; + +const NOTIFICATION_TYPES = { + BILLING_QUOTA_EXCEEDING: 'BILLING_QUOTA_EXCEEDING', + DATASTORAGE_LIFECYCLE_ACTION: 'DATASTORAGE_LIFECYCLE_ACTION', + DATASTORAGE_LIFECYCLE_RESTORE_ACTION: 'DATASTORAGE_LIFECYCLE_RESTORE_ACTION', + FULL_NODE_POOL: 'FULL_NODE_POOL', + HIGH_CONSUMED_RESOURCES: 'HIGH_CONSUMED_RESOURCES', + IDLE_RUN: 'IDLE_RUN', + IDLE_RUN_PAUSED: 'IDLE_RUN_PAUSED', + IDLE_RUN_STOPPED: 'IDLE_RUN_STOPPED', + LONG_INIT: 'LONG_INIT', + LONG_PAUSED: 'LONG_PAUSED', + LONG_PAUSED_STOPPED: 'LONG_PAUSED_STOPPED', + LONG_RUNNING: 'LONG_RUNNING', + LONG_STATUS: 'LONG_STATUS', + NEW_ISSUE: 'NEW_ISSUE', + NEW_ISSUE_COMMENT: 'NEW_ISSUE_COMMENT', + PIPELINE_RUN_STATUS: 'PIPELINE_RUN_STATUS', + STORAGE_QUOTA_EXCEEDING: 'STORAGE_QUOTA_EXCEEDING', + INACTIVE_USERS: 'INACTIVE_USERS', + LDAP_BLOCKED_POSTPONED_USERS: 'LDAP_BLOCKED_POSTPONED_USERS', + LDAP_BLOCKED_USERS: 'LDAP_BLOCKED_USERS' +}; + +export {ACTIONS, ENTITY_CLASSES, NOTIFICATION_TYPES}; diff --git a/client/src/components/main/notification/notification-actions/index.js b/client/src/components/main/notification/notification-actions/index.js new file mode 100644 index 0000000000..aa4bda0548 --- /dev/null +++ b/client/src/components/main/notification/notification-actions/index.js @@ -0,0 +1,232 @@ +/* + * Copyright 2017-2023 EPAM Systems, Inc. (https://www.epam.com/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {inject, observer} from 'mobx-react'; +import {computed, observable} from 'mobx'; +import { + Dropdown, + Icon, + Menu, + message +} from 'antd'; +import PipelineRunInfo from '../../../../models/pipelines/PipelineRunInfo'; +import styles from './notification-actions.css'; +import {ACTIONS, ENTITY_CLASSES, NOTIFICATION_TYPES} from './actions'; + +@inject('preferences') +@observer +class NotificationActions extends React.Component { + state = { + visible: false, + pending: false, + entityRequestPending: false + }; + + @observable + entityInfoRequest; + + groupedActions = { + [NOTIFICATION_TYPES.IDLE_RUN]: [ + ACTIONS.viewRun, + ACTIONS.pauseRun, + ACTIONS.stopRun + ], + [NOTIFICATION_TYPES.IDLE_RUN_PAUSED]: [ + ACTIONS.viewRun, + ACTIONS.resumeRun, + ACTIONS.terminateRun + ], + [NOTIFICATION_TYPES.IDLE_RUN_STOPPED]: [ + ACTIONS.viewRun + ], + [NOTIFICATION_TYPES.HIGH_CONSUMED_RESOURCES]: [ + ACTIONS.viewRun + ], + [NOTIFICATION_TYPES.LONG_INIT]: [ + ACTIONS.viewRun, + ACTIONS.terminateRun + ], + [NOTIFICATION_TYPES.LONG_PAUSED]: [ + ACTIONS.viewRun, + ACTIONS.resumeRun, + ACTIONS.terminateRun + ], + [NOTIFICATION_TYPES.LONG_PAUSED_STOPPED]: [ + ACTIONS.viewRun + ], + [NOTIFICATION_TYPES.LONG_RUNNING]: [ + ACTIONS.viewRun, + ACTIONS.pauseRun, + ACTIONS.stopRun + ], + [NOTIFICATION_TYPES.LONG_STATUS]: [ + ACTIONS.viewRun + ], + [NOTIFICATION_TYPES.PIPELINE_RUN_STATUS]: [ + ACTIONS.viewRun + ], + [NOTIFICATION_TYPES.DATASTORAGE_LIFECYCLE_RESTORE_ACTION]: [ + ACTIONS.openDatastorage + ], + [NOTIFICATION_TYPES.STORAGE_QUOTA_EXCEEDING]: [ + ACTIONS.openDatastorage + ], + [NOTIFICATION_TYPES.DATASTORAGE_LIFECYCLE_ACTION]: [ + ACTIONS.openDatastorage, + ACTIONS.postponeLifecycleRule + ], + [NOTIFICATION_TYPES.FULL_NODE_POOL]: [ + ACTIONS.openPoolsUsage + ] + }; + + @computed + get actions () { + const {preferences} = this.props; + const {type} = this.notificationDetails; + const actions = this.groupedActions[type] || []; + const entityValue = this.entityInfoRequest && this.entityInfoRequest.loaded + ? this.entityInfoRequest.value + : undefined; + return actions + .filter(Boolean) + .filter(({available}) => available(entityValue, preferences)); + } + + get notificationDetails () { + const {notification = {}} = this.props; + const details = (notification.resources || [])[0] || {}; + return { + ...details, + type: notification.type + }; + } + + get showActionsControl () { + const {type} = this.notificationDetails; + return type && type !== NOTIFICATION_TYPES.INACTIVE_USERS && + type !== NOTIFICATION_TYPES.LDAP_BLOCKED_POSTPONED_USERS && + type !== NOTIFICATION_TYPES.LDAP_BLOCKED_USERS; + } + + fetchEntityInfo = () => { + return new Promise(resolve => { + const {entityClass, entityId} = this.notificationDetails; + switch (entityClass) { + case ENTITY_CLASSES.RUN: + this.entityInfoRequest = new PipelineRunInfo(entityId); + break; + } + if (!this.entityInfoRequest) { + resolve(); + return; + } + this.setState({ + entityRequestPending: true + }, async () => { + await this.entityInfoRequest.fetch(); + if (this.entityInfoRequest.error) { + message.error(this.entityInfoRequest.error); + } + this.setState({ + entityRequestPending: false + }, () => resolve()); + }); + }); + }; + + showMenu = () => { + this.setState({visible: true}); + }; + + hideMenu = () => { + this.setState({visible: false}); + }; + + handleVisibleChange = async (visible) => { + if (!visible) { + return this.hideMenu(); + } + await this.fetchEntityInfo(); + this.showMenu(); + }; + + render () { + const { + pending, + notification, + style + } = this.props; + const {visible, entityRequestPending} = this.state; + const menu = ( + { + const action = this.actions.find(action => action.key === key); + action && action.actionFn({ + notification, + entity: (this.entityInfoRequest || {}).value, + router: this.props.router, + callback: this.hideMenu + }); + }} + > + {this.actions.length > 0 ? ( + this.actions.map(action => ( + + {action.key} + + )) + ) : ( + No actions available + )} + + ); + return ( +
+ {this.showActionsControl ? ( + e.stopPropagation()} + > + + + ) : null} +
+ ); + } +} + +NotificationActions.propTypes = { + style: PropTypes.object, + notification: PropTypes.object, + router: PropTypes.object +}; + +export default NotificationActions; diff --git a/client/src/components/main/notification/notification-actions/notification-actions.css b/client/src/components/main/notification/notification-actions/notification-actions.css new file mode 100644 index 0000000000..18d921d28a --- /dev/null +++ b/client/src/components/main/notification/notification-actions/notification-actions.css @@ -0,0 +1,19 @@ +/* + * Copyright 2017-2023 EPAM Systems, Inc. (https://www.epam.com/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.controls-icon { + font-size: larger; +} diff --git a/client/src/models/dataStorage/lifeCycleRules/DataStorageLifeCycleRulesPostpone.js b/client/src/models/dataStorage/lifeCycleRules/DataStorageLifeCycleRulesPostpone.js new file mode 100644 index 0000000000..f06bdc3ff0 --- /dev/null +++ b/client/src/models/dataStorage/lifeCycleRules/DataStorageLifeCycleRulesPostpone.js @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2022 EPAM Systems, Inc. (https://www.epam.com/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Remote from '../../basic/Remote'; + +const DEFAULT_DAYS = 14; + +export default class DataStorageLifeCycleRulesPostpone extends Remote { + url; + + constructor ({ + datastorageId, + ruleId, + path, + days = DEFAULT_DAYS, + force = false + }) { + super(); + const parts = [ + path !== undefined && `path=${encodeURIComponent(path)}`, + days !== undefined && `days=${days}`, + force !== undefined && `force=${force}` + ].filter(Boolean); + const query = `?${parts.join('&')}`; + this.url = `/datastorage/${datastorageId}/lifecycle/rule/${ruleId}/prolong${query}`; + } +}