From 277be18f538c77b665b7fcbb8fccc75cec25ebda Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Wed, 18 May 2022 17:00:24 +0200 Subject: [PATCH] Add Events view Changes on the VM Details -> Overview Card: 1. display last 2 events that are associated with the VM 2. provide a "View All" link to a new sub-page: the Events view. Events view features: 1. display events in a table with 3 columns: a) severity b) date (includes time) c) message 2. allow filtering and sorting for all columns via toolbar 3. limit the max number of events that can be fetched to 500 4. do incremental refresh by fetching only new events --- src/actions/userMessages.js | 57 ++++++- src/components/Events/EventFilters.js | 77 +++++++++ src/components/Events/EventSort.js | 44 +++++ src/components/Events/EventStatus.js | 18 ++ src/components/Events/EventsTable.js | 161 ++++++++++++++++++ src/components/Events/index.js | 4 + src/components/Events/style.css | 3 + src/components/Pages/index.js | 11 +- src/components/Toolbar/index.js | 39 +++++ .../VmDetails/cards/OverviewCard/index.js | 42 ++++- .../VmDetails/cards/OverviewCard/style.css | 30 +++- src/constants/index.js | 5 + src/constants/pages.js | 1 + src/intl/messages.js | 16 ++ src/ovirtapi/index.js | 10 ++ src/reducers/userMessages.js | 28 +++ src/routes.js | 13 ++ src/sagas/background-refresh.js | 25 ++- src/sagas/index.js | 27 +++ 19 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 src/components/Events/EventFilters.js create mode 100644 src/components/Events/EventSort.js create mode 100644 src/components/Events/EventStatus.js create mode 100644 src/components/Events/EventsTable.js create mode 100644 src/components/Events/index.js create mode 100644 src/components/Events/style.css diff --git a/src/actions/userMessages.js b/src/actions/userMessages.js index ba4cac30c..fa1a00ed5 100644 --- a/src/actions/userMessages.js +++ b/src/actions/userMessages.js @@ -1,12 +1,17 @@ import { + ADD_LAST_VM_EVENTS, ADD_USER_MESSAGE, + ADD_VM_EVENTS, AUTO_ACKNOWLEDGE, CLEAR_USER_MSGS, DISMISS_EVENT, DISMISS_USER_MSG, + GET_ALL_EVENTS, + GET_VM_EVENTS, + SAVE_EVENT_FILTERS, + SET_EVENT_SORT, SET_USERMSG_NOTIFIED, SET_SERVER_MESSAGES, - GET_ALL_EVENTS, } from '_/constants' export function addUserMessage ({ message, messageDescriptor, type = '' }) { @@ -77,3 +82,53 @@ export function setServerMessages ({ messages }) { export function getAllEvents () { return { type: GET_ALL_EVENTS } } + +export function addVmEvents ({ events = [], vmId }) { + return { + type: ADD_VM_EVENTS, + payload: { + events, + vmId, + }, + } +} + +export function addLastVmEvents ({ events = [], vmId }) { + return { + type: ADD_LAST_VM_EVENTS, + payload: { + events, + vmId, + }, + } +} + +export function getVmEvents ({ vmId, vmName, newestEventId = 0, maxItems = 0 }) { + return { + type: GET_VM_EVENTS, + payload: { + vmId, + vmName, + newestEventId, + maxItems, + }, + } +} + +export function setEventSort ({ sort }) { + return { + type: SET_EVENT_SORT, + payload: { + sort, + }, + } +} + +export function saveEventFilters ({ filters }) { + return { + type: SAVE_EVENT_FILTERS, + payload: { + filters, + }, + } +} diff --git a/src/components/Events/EventFilters.js b/src/components/Events/EventFilters.js new file mode 100644 index 000000000..1b01f359c --- /dev/null +++ b/src/components/Events/EventFilters.js @@ -0,0 +1,77 @@ +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { enumMsg, withMsg } from '_/intl' +import { saveEventFilters } from '_/actions' +import { localeCompare, toJS } from '_/helpers' + +import { Filters } from '_/components/Toolbar' + +export const SEVERITY = 'severity' +export const DATE = 'date' +export const MESSAGE = 'message' +export const UNKNOWN = 'unknown' + +export const EVENT_SEVERITY = { + error: 3, + warning: 2, + normal: 1, + [UNKNOWN]: 0, +} + +const composeSeverity = (msg, locale) => { + return { + id: SEVERITY, + title: msg.severity(), + placeholder: msg.eventsFilterTypePlaceholderSeverity(), + filterValues: Object.entries( + Object.keys(EVENT_SEVERITY) + .map((status) => ({ title: enumMsg('EventSeverity', status, msg), id: status })) + .reduce((acc, { title, id }) => { + acc[title] = { ...acc[title], [id]: id } + return acc + }, {})) + .map(([title, ids]) => ({ title, ids })) + .sort((a, b) => localeCompare(a.title, b.title, locale)), + } +} + +const EventFilters = ({ msg, locale, selectedFilters = {}, onFilterUpdate }) => { + const filterTypes = useMemo(() => [ + composeSeverity(msg, locale), + { + id: DATE, + title: msg.date(), + datePicker: true, + }, + { + id: MESSAGE, + title: msg.message(), + placeholder: msg.eventsFilterTypePlaceholderMessage(), + }, + ], [msg, locale]) + return ( + + ) +} + +EventFilters.propTypes = { + selectedFilters: PropTypes.object, + onFilterUpdate: PropTypes.func.isRequired, + msg: PropTypes.object.isRequired, + locale: PropTypes.string.isRequired, +} + +export default connect( + ({ userMessages }) => ({ + selectedFilters: toJS(userMessages.get('eventFilters')), + }), + (dispatch) => ({ + onFilterUpdate: (filters) => dispatch(saveEventFilters({ filters })), + }) +)(withMsg(EventFilters)) diff --git a/src/components/Events/EventSort.js b/src/components/Events/EventSort.js new file mode 100644 index 000000000..1891a46a4 --- /dev/null +++ b/src/components/Events/EventSort.js @@ -0,0 +1,44 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { Sort } from '_/components/Toolbar' +import { setEventSort } from '_/actions' +import { withMsg } from '_/intl' +import { toJS } from '_/helpers' + +import { SEVERITY, DATE, MESSAGE } from './EventFilters' + +const SortFields = { + [SEVERITY]: { + id: SEVERITY, + messageDescriptor: { id: 'severity' }, + }, + [DATE]: { + id: DATE, + messageDescriptor: { id: 'date' }, + }, + [MESSAGE]: { + id: MESSAGE, + messageDescriptor: { id: 'message' }, + }, +} + +const EventSort = ({ sort = { ...SortFields[DATE], isAsc: false }, onSortChange }) => + +EventSort.propTypes = { + sort: PropTypes.shape({ + id: PropTypes.string.isRequired, + messageDescriptor: PropTypes.object.isRequired, + isAsc: PropTypes.bool, + }), + onSortChange: PropTypes.func.isRequired, +} + +export default connect( + ({ userMessages }) => ({ + sort: toJS(userMessages.get('eventSort')), + }), + (dispatch) => ({ + onSortChange: (sort) => dispatch(setEventSort({ sort })), + }) +)(withMsg(EventSort)) diff --git a/src/components/Events/EventStatus.js b/src/components/Events/EventStatus.js new file mode 100644 index 000000000..a61aac444 --- /dev/null +++ b/src/components/Events/EventStatus.js @@ -0,0 +1,18 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { ExclamationTriangleIcon, ExclamationCircleIcon } from '@patternfly/react-icons/dist/esm/icons' + +export const EventStatus = ({ severity }) => { + switch (severity) { + case 'error': + return + case 'warning': + return + default: + return null + } +} + +EventStatus.propTypes = { + severity: PropTypes.oneOf(['error', 'warning', 'normal']), +} diff --git a/src/components/Events/EventsTable.js b/src/components/Events/EventsTable.js new file mode 100644 index 000000000..f5a52c626 --- /dev/null +++ b/src/components/Events/EventsTable.js @@ -0,0 +1,161 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { withMsg } from '_/intl' +// import moment from 'moment' + +import { + TableComposable, + Tbody, + Th, + Thead, + Td, + Tr, +} from '@patternfly/react-table' + +import { + Button, + // ButtonVariant, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + // EmptyStateSecondaryActions, + // HelperText, + // HelperTextItem, + Spinner, + Title, +// TextInput, +// Tooltip, +} from '@patternfly/react-core' + +import { + SearchIcon, +} from '@patternfly/react-icons/dist/esm/icons' + +import { EventStatus } from './EventStatus' + +import style from './style.css' +import { localeCompare, toJS } from '_/helpers' + +import { saveEventFilters } from '_/actions' + +import { SEVERITY, DATE, MESSAGE, EVENT_SEVERITY, UNKNOWN } from './EventFilters' +import moment from 'moment' + +const sortEvents = (events = [], { id, isAsc } = {}, locale) => { + if (!id) { + return + } + const direction = isAsc ? 1 : -1 + const getField = (event, id) => { + switch (id) { + case SEVERITY: + return '' + EVENT_SEVERITY[event?.severity ?? UNKNOWN] ?? EVENT_SEVERITY[UNKNOWN] + case DATE: + return '' + event?.time ?? '' + case MESSAGE: + return event?.description + } + } + + events.sort((a, b) => direction * localeCompare(getField(a, id), getField(b, id), locale)) +} + +const EventsTable = ({ + msg, + locale, + events, + eventFilters: { [SEVERITY]: severityFilters, [DATE]: dateFilters, [MESSAGE]: messageFilters }, + eventSort, + clearAllFilters, +}) => { + const columnNames = { + [SEVERITY]: msg.severity(), + [DATE]: msg.date(), + [MESSAGE]: msg.message(), + } + + const filteredEvents = events?.filter(({ severity, time, description }) => { + const ackFromSeverity = !severityFilters?.length || severityFilters?.some(level => level === severity) + const ackFromTime = !dateFilters?.length || dateFilters?.some(isoDateStr => moment(time).isSame(isoDateStr, 'day')) + const ackFromMessage = !messageFilters?.length || messageFilters?.some(str => description?.includes(str)) + return ackFromSeverity && ackFromTime && ackFromMessage + }) + + sortEvents(filteredEvents, eventSort, locale) + + return ( +
+ { !filteredEvents && ( + + + + ) } + + { filteredEvents?.length === 0 && ( + + + + {msg.noEventsFound()} + + {msg.clearAllFiltersAndTryAgain()} + + + ) } + + { filteredEvents?.length > 0 && ( + <> + + + + {columnNames[SEVERITY]} + {columnNames[DATE]} + {columnNames[MESSAGE]} + + + + {filteredEvents.map(({ id, severity, time, description }) => { + return ( + + + {new Date(time).toLocaleString(locale)} + {description} + + ) + })} + + + + ) } +
+ ) +} + +EventsTable.propTypes = { + msg: PropTypes.object.isRequired, + locale: PropTypes.string.isRequired, + events: PropTypes.array, + eventFilters: PropTypes.object.isRequired, + eventSort: PropTypes.shape({ + id: PropTypes.string.isRequired, + messageDescriptor: PropTypes.object.isRequired, + isAsc: PropTypes.bool, + }), + clearAllFilters: PropTypes.func.isRequired, + +} + +export default connect( + ({ userMessages }, { vmId }) => ({ + events: toJS(userMessages.getIn(['events', vmId])), + eventFilters: toJS(userMessages.getIn(['eventFilters'], {})), + eventSort: toJS(userMessages.getIn(['eventSort'])), + }), + (dispatch) => ({ + clearAllFilters: () => dispatch(saveEventFilters({ filters: {} })), + }) +)(withMsg(EventsTable)) diff --git a/src/components/Events/index.js b/src/components/Events/index.js new file mode 100644 index 000000000..46ae6d46c --- /dev/null +++ b/src/components/Events/index.js @@ -0,0 +1,4 @@ +export { default as EventsTable } from './EventsTable' +export * from './EventStatus' +export { default as EventFilters } from './EventFilters' +export { default as EventSort } from './EventSort' diff --git a/src/components/Events/style.css b/src/components/Events/style.css new file mode 100644 index 000000000..163500cb5 --- /dev/null +++ b/src/components/Events/style.css @@ -0,0 +1,3 @@ +.container { + padding: 20px; +} diff --git a/src/components/Pages/index.js b/src/components/Pages/index.js index aa1db4daa..af9ead43f 100644 --- a/src/components/Pages/index.js +++ b/src/components/Pages/index.js @@ -10,7 +10,7 @@ import VmDetails from '../VmDetails' import VmConsole from '../VmConsole' import Handler404 from '_/Handler404' import { GlobalSettings } from '../UserSettings' - +import { EventsTable } from '_/components/Events' /** * Route component (for PageRouter) to view the list of VMs and Pools */ @@ -106,9 +106,18 @@ const VmConsolePageConnected = connect( (dispatch) => ({}) )(VmConsolePage) +const VmEventsPage = ({ match }) => { + return +} + +VmEventsPage.propTypes = { + match: RouterPropTypeShapes.match.isRequired, +} + export { VmConsolePageConnected as VmConsolePage, VmDetailsPageConnected as VmDetailsPage, VmsListPage, GlobalSettingsPage, + VmEventsPage, } diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js index 2bb2daec2..dd30d4f25 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -12,7 +12,12 @@ import VmActions from '../VmActions' import VmConsoleSelector from '../VmConsole/VmConsoleSelector' import VmConsoleInstructionsModal from '../VmConsole/VmConsoleInstructionsModal' import VmsListToolbar from './VmsListToolbar' +import { + EventFilters, + EventSort, +} from '../Events' import { NATIVE_VNC, SPICE } from '_/constants' +import { saveEventFilters } from '_/actions' const VmDetailToolbar = ({ match, vms }) => { if (vms.getIn(['vms', match.params.id])) { @@ -43,6 +48,36 @@ const VmDetailToolbarConnected = connect( }) )(VmDetailToolbar) +const EventsToolbar = ({ match, vms, onClearFilters }) => { + if (!vms.getIn(['vms', match?.params?.id])) { + return null + } + return ( + + + + + + + ) +} + +EventsToolbar.propTypes = { + vms: PropTypes.object.isRequired, + onClearFilters: PropTypes.func.isRequired, + + match: RouterPropTypeShapes.match.isRequired, +} + +const EventsToolbarConnected = connect( + (state) => ({ + vms: state.vms, + }), + (dispatch) => ({ + onClearFilters: () => dispatch(saveEventFilters({ filters: {} })), + }) +)(EventsToolbar) + const VmConsoleToolbar = ({ match: { params: { id, consoleType } } = {}, vms }) => { if (!vms.getIn(['vms', id])) { return @@ -84,7 +119,11 @@ const SettingsToolbar = () =>
export { VmDetailToolbarConnected as VmDetailToolbar, + EventsToolbarConnected as EventsToolbar, VmConsoleToolbarConnected as VmConsoleToolbar, VmsListToolbar, SettingsToolbar, } + +export { default as Sort } from './Sort' +export { default as Filters } from './Filters' diff --git a/src/components/VmDetails/cards/OverviewCard/index.js b/src/components/VmDetails/cards/OverviewCard/index.js index 8f4bd28ea..bf6dfaf7a 100644 --- a/src/components/VmDetails/cards/OverviewCard/index.js +++ b/src/components/VmDetails/cards/OverviewCard/index.js @@ -1,13 +1,15 @@ import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' +import { push } from 'connected-react-router' import sharedStyle from '../../../sharedStyle.css' import { getOsHumanName, getVmIcon, isVmNameValid, isHostNameValid } from '_/components/utils' import { enumMsg, withMsg } from '_/intl' -import { generateUnique, buildMessageFromRecord } from '_/helpers' +import { generateUnique, buildMessageFromRecord, toJS } from '_/helpers' import { formatUptimeDuration } from '_/utils' import { editVm } from '_/actions' +import { formatHowLongAgo } from '_/utils/format' import { Alert, @@ -16,11 +18,17 @@ import { FormGroup, TextArea, TextInput, + List, + ListItem, + Button, + Label, + Tooltip, } from '@patternfly/react-core' import BaseCard from '../../BaseCard' import VmIcon from '../../../VmIcon' import VmStatusIcon from '../../../VmStatusIcon' +import { EventStatus } from '_/components/Events' import style from './style.css' /** @@ -196,7 +204,7 @@ class OverviewCard extends React.Component { } render () { - const { vm, icons, vms, operatingSystems, isEditable, msg } = this.props + const { vm, icons, vms, operatingSystems, isEditable, msg, locale, lastEvents, goToEventsPage } = this.props const { isEditing, correlatedMessages, nameError, updateCloudInit, disableHostnameToggle } = this.state const elapsedUptime = vm.getIn(['statistics', 'elapsedUptime', 'firstDatum'], 0) @@ -232,7 +240,7 @@ class OverviewCard extends React.Component { > {({ isEditing }) => { return ( -
+ <>
{getOsHumanName(vm.getIn(['os', 'type']))}
@@ -318,7 +326,26 @@ class OverviewCard extends React.Component { ) ) } -
+ + { !isEditing && ( + <> + + + + { lastEvents.length === 0 && {msg.noEventsFound()}} + { lastEvents.map(({ id, time, description, severity }) => ( + {new Date(time).toLocaleString(locale)} {description}}> + + + {description} + + + )) + } + + + )} + ) }} @@ -336,10 +363,13 @@ OverviewCard.propTypes = { operatingSystems: PropTypes.object.isRequired, // deep immutable, {[id: string]: OperatingSystem} userMessages: PropTypes.object.isRequired, templates: PropTypes.object.isRequired, + lastEvents: PropTypes.array, saveChanges: PropTypes.func.isRequired, + goToEventsPage: PropTypes.func.isRequired, msg: PropTypes.object.isRequired, + locale: PropTypes.string.isRequired, } export default connect( @@ -350,8 +380,10 @@ export default connect( userMessages: state.userMessages, templates: state.templates, isEditable: vm.get('canUserEditVm'), + lastEvents: toJS(state.userMessages.getIn(['lastEvents', vm.get('id')], [])), }), - (dispatch) => ({ + (dispatch, { vm }) => ({ saveChanges: (minimalVmChanges, correlationId) => dispatch(editVm({ vm: minimalVmChanges }, { correlationId })), + goToEventsPage: () => dispatch(push(`/vm/${vm.get('id')}/events`)), }) )(withMsg(OverviewCard)) diff --git a/src/components/VmDetails/cards/OverviewCard/style.css b/src/components/VmDetails/cards/OverviewCard/style.css index 6111c3b04..3601ffda2 100644 --- a/src/components/VmDetails/cards/OverviewCard/style.css +++ b/src/components/VmDetails/cards/OverviewCard/style.css @@ -2,6 +2,28 @@ * Styles for the Overview Card */ +.bold { + font-weight: bold; + } + + .fullWidth { + width: 100%; +} + +.event { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; +} + + +.event :global(.pf-c-label) { + margin-right: 5px; + /* width: 100%; */ +} + .operating-system-label { position: absolute; top: 0; @@ -11,7 +33,9 @@ .vm-info { padding: 15px; - flex-grow: 1 + flex-grow: 1; + display: flex; + flex-direction: column; } .vm-name { @@ -57,13 +81,13 @@ } .os-icon { - padding: 15px; + padding: 15px; } .container { display: flex; overflow: visible; - flex-flow: row wrap; + flex-flow: row nowrap; } .pool-vm-label { diff --git a/src/constants/index.js b/src/constants/index.js index 168810d94..9f44e2960 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -5,11 +5,13 @@ export const ACTION_IN_PROGRESS_START = 'ACTION_IN_PROGRESS_START' export const ACTION_IN_PROGRESS_STOP = 'ACTION_IN_PROGRESS_STOP' export const ADD_ACTIVE_REQUEST = 'ADD_ACTIVE_REQUEST' export const ADD_DISK_REMOVAL_PENDING_TASK = 'ADD_DISK_REMOVAL_PENDING_TASK' +export const ADD_LAST_VM_EVENTS = 'ADD_LAST_VM_EVENTS' export const ADD_NETWORKS_TO_VNIC_PROFILES = 'ADD_NETWORKS_TO_VNIC_PROFILES' export const ADD_SNAPSHOT_ADD_PENDING_TASK = 'ADD_SNAPSHOT_ADD_PENDING_TASK' export const ADD_SNAPSHOT_REMOVAL_PENDING_TASK = 'ADD_SNAPSHOT_REMOVAL_PENDING_TASK' export const ADD_SNAPSHOT_RESTORE_PENDING_TASK = 'ADD_SNAPSHOT_RESTORE_PENDING_TASK' export const ADD_VM_NIC = 'ADD_VM_NIC' +export const ADD_VM_EVENTS = 'ADD_VM_EVENTS' export const ADD_USER_MESSAGE = 'ADD_USER_MESSAGE' export const APP_CONFIGURED = 'APP_CONFIGURED' export const AUTO_ACKNOWLEDGE = 'AUTO_ACKNOWLEDGE' @@ -44,6 +46,7 @@ export const GET_POOL = 'GET_POOL' export const GET_POOLS = 'GET_POOLS' export const GET_VM = 'GET_VM' export const GET_VM_CDROM = 'GET_VM_CDROM' +export const GET_VM_EVENTS = 'GET_VM_EVENTS' export const GET_VMS = 'GET_VMS' export const LOAD_USER_OPTIONS: 'LOAD_USER_OPTIONS' = 'LOAD_USER_OPTIONS' export const LOGIN = 'LOGIN' @@ -67,6 +70,7 @@ export const REMOVE_VM = 'REMOVE_VM' export const RESTART_VM = 'RESTART_VM' export const SAVE_CONSOLE_OPTIONS = 'SAVE_CONSOLE_OPTIONS' export const SAVE_FILTERS = 'SAVE_FILTERS' +export const SAVE_EVENT_FILTERS = 'SAVE_EVENT_FILTERS' export const SAVE_GLOBAL_OPTIONS: 'SAVE_GLOBAL_OPTIONS' = 'SAVE_GLOBAL_OPTIONS' export const SAVE_SSH_KEY = 'SAVE_SSH_KEY' export const SET_ADMINISTRATOR = 'SET_ADMINISTRATOR' @@ -81,6 +85,7 @@ export const SET_CPU_TOPOLOGY_OPTIONS = 'SET_CPU_TOPOLOGY_OPTIONS' export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE' export const SET_DATA_CENTERS = 'SET_DATA_CENTERS' export const SET_DEFAULT_TIMEZONE = 'SET_DEFAULT_TIMEZONE' +export const SET_EVENT_SORT = 'SET_EVENT_SORT' export const SET_FILTERS = 'SET_FILTERS' export const SET_HOSTS = 'SET_HOSTS' export const SET_OPERATING_SYSTEMS = 'SET_OPERATING_SYSTEMS' diff --git a/src/constants/pages.js b/src/constants/pages.js index ad2a897b9..2bef38e4b 100644 --- a/src/constants/pages.js +++ b/src/constants/pages.js @@ -5,3 +5,4 @@ export const LIST_PAGE_TYPE = 'listPage' export const NO_REFRESH_TYPE = 'noRefreshPage' export const EMPTY_VNIC_PROFILE_ID = '' export const SETTINGS_PAGE_TYPE = 'settingsPage' +export const EVENTS_PAGE_TYPE = 'eventsPage' diff --git a/src/intl/messages.js b/src/intl/messages.js index e7c03f623..aa3c5e4bf 100644 --- a/src/intl/messages.js +++ b/src/intl/messages.js @@ -80,6 +80,7 @@ export const messages: { [messageId: string]: MessageType } = { clear: 'Clear', clearAll: 'Clear all', clearAllFilters: 'Clear All Filters', + clearAllFiltersAndTryAgain: 'Clear all filters and try again', clearMessages: 'Clear Messages', clearAutoconnectVmNotAvailable: 'VM chosen for automatic console connection is no longer available. The setting will be cleared.', clickForHelp: 'Click for help', @@ -189,6 +190,7 @@ export const messages: { [messageId: string]: MessageType } = { customScript: 'Custom script', dataCenter: { message: 'Data Center', description: 'Label for the VM\'s data center' }, dataCenterChangesWithCluster: 'The data center cannot be edited from here. Please contact your administrator if you would like to edit the data center.', + date: 'Date', defaultButton: 'Default', defaultOption: { message: '(Default)', @@ -266,6 +268,10 @@ export const messages: { [messageId: string]: MessageType } = { enum_DiskInterface_ide: { message: 'IDE', description: 'IDE controller VM disk attachment interface' }, enum_DiskInterface_virtio: { message: 'VirtIO', description: 'virtio controller VM disk attachment interface' }, enum_DiskInterface_virtio_scsi: { message: 'VirtIO-SCSI', description: 'virtio SCSI controller VM disk attachment interface' }, + enum_EventSeverity_error: 'Error', + enum_EventSeverity_warning: 'Warning', + enum_EventSeverity_normal: 'Information', + enum_EventSeverity_unknown: 'Unknown', enum_NicInterface_e1000: { message: 'e1000', description: 'Display name of a NIC that provides an E1000 based interface to the VM', @@ -352,6 +358,9 @@ export const messages: { [messageId: string]: MessageType } = { }, error: 'Error', errorWhileCreatingNewDisk: 'Error while creating new disk:', + events: 'Events', + eventsFilterTypePlaceholderMessage: 'Filter by Message', + eventsFilterTypePlaceholderSeverity: 'Filter by Severity', every30Seconds: 'Every 30 seconds', everyMinute: 'Every minute', every2Minute: 'Every 2 minutes', @@ -414,11 +423,13 @@ export const messages: { [messageId: string]: MessageType } = { ifVmIsRunningClickToAccessItsGraphicsConsole: 'If the virtual machine is running, click the protocol name to access its Graphical Console.', info: 'Information', inPreview: 'In Preview', + invalidDateFormat: 'Invalid date format.The supported format is {format}', ieNotSupported: 'Internet Explorer is not a supported browser.', ipAddress: { message: 'IP Address', description: 'Label for IP addresses reported by VM guest agent' }, isPersistMemorySnapshot: 'Content of the memory of the virtual machine is included in the snapshot.', itemDoesntExistOrDontHavePermissions: 'The item doesn\'t exist or you do not have the permissions to view it.', language: 'Language', + lastEvents: 'Last Events', less: { message: 'less', description: 'more/less pair used to control collapsible long listing', @@ -447,6 +458,7 @@ export const messages: { [messageId: string]: MessageType } = { memory: 'Memory', memoryIncluded: '(State included)', messages: 'Messages', + message: 'Message', more: { message: 'more', description: 'more/less pair used to control collapsible long listing', @@ -498,6 +510,7 @@ export const messages: { [messageId: string]: MessageType } = { noClustersAvailable: 'No Clusters available', noDisks: 'no disks', noError: 'No error', + noEventsFound: 'No events found', noMessages: 'There are no notifications to display.', noneItem: '[None]', noNetwork: 'No network', @@ -621,6 +634,7 @@ export const messages: { [messageId: string]: MessageType } = { message: 'Your session is about to timeout due to inactivity.', description: 'Primary message for SessionTimeout modal component', }, + severity: 'Severity', shutdown: 'Shutdown', shutdownStatelessPoolVm: 'This virtual machine belongs to {poolName} and is stateless so any data that is currently attached to the virtual machine will be lost if it is shutdown. The virtual machine will be returned to {poolName} if shutdown.', shutdownVm: 'Shutdown the VM', @@ -682,6 +696,7 @@ export const messages: { [messageId: string]: MessageType } = { timezone: 'Timezone', thisOperationCantBeUndone: 'This operation cannot be undone.', threadsPerCores: 'Threads per Core', + toggleDatePicker: 'Toggle date picker', totalCountOfVirtualProcessorsVmWillBeEquippedWith: 'Total count of virtual processors the virtual machine will be equipped with.', totalSocketsCpuTooltipMessage: '{number} virtual sockets', totalCoresCpuTooltipMessage: '{number} cores per socket', @@ -768,6 +783,7 @@ export const messages: { [messageId: string]: MessageType } = { username: 'Username', usingRemoteViewer: 'Using a remote viewer relies on a downloaded .vv file.', vcpuTopology: 'VCPU Topology', + viewAll: 'View All', viewAllVirtualMachines: 'View All Virtual Machines', virtualMachines: 'Virtual Machines', virtualSockets: 'Virtual Sockets', diff --git a/src/ovirtapi/index.js b/src/ovirtapi/index.js index 3af29f5ab..f71a5e67b 100644 --- a/src/ovirtapi/index.js +++ b/src/ovirtapi/index.js @@ -305,6 +305,16 @@ const OvirtApi = { assertLogin({ methodName: 'dismissEvent' }) return httpDelete({ url: `${AppConfiguration.applicationContext}/api/events/${eventId}` }) }, + eventsForVm ({ vmName, newestEventId = 0, maxItems = 0 }: Object): Promise { + assertLogin({ methodName: 'eventsForVm' }) + const query = [ + 'search=' + encodeURIComponent(`vm.name=${vmName}`), + !!newestEventId && `from=${newestEventId}`, + !!maxItems && `max=${maxItems}`, + ].filter(Boolean).join('&') + + return httpGet({ url: `${AppConfiguration.applicationContext}/api/events?${query}` }) + }, checkFilter (): Promise { assertLogin({ methodName: 'checkFilter' }) diff --git a/src/reducers/userMessages.js b/src/reducers/userMessages.js index 4e724e45b..f3947938e 100644 --- a/src/reducers/userMessages.js +++ b/src/reducers/userMessages.js @@ -1,13 +1,18 @@ // @flow import * as Immutable from 'immutable' +import { fromJS } from 'immutable' import { + ADD_LAST_VM_EVENTS, ADD_USER_MESSAGE, + ADD_VM_EVENTS, AUTO_ACKNOWLEDGE, DISMISS_USER_MSG, FAILED_EXTERNAL_ACTION, LOGIN_FAILED, + SET_EVENT_SORT, SET_USERMSG_NOTIFIED, SET_SERVER_MESSAGES, + SAVE_EVENT_FILTERS, CLEAR_USER_MSGS, } from '_/constants' import { actionReducer } from './utils' @@ -38,6 +43,8 @@ function removeEvents (targetIds: Set, state: any): any { const initialState = Immutable.fromJS({ records: [], + events: {}, + lastEvents: {}, autoAcknowledge: false, }) @@ -97,6 +104,27 @@ const userMessages: any = actionReducer(initialState, { return state.set('autoAcknowledge', autoAcknowledge) }, + [ADD_VM_EVENTS] (state: any, { payload: { events, vmId } }: any): any { + const allEvents = state.getIn(['events', vmId], fromJS([])) + const all = allEvents.toJS().map(({ id }) => id) + const filteredEvents = events.filter(({ id, ...rest }) => { + if (all[id]) { + console.warn('duplicate', id, rest) + } + return !all[id] + }) + return state.setIn(['events', vmId], allEvents.concat(fromJS(filteredEvents))) + }, + [ADD_LAST_VM_EVENTS] (state: any, { payload: { events, vmId } }: any): any { + return state.setIn(['lastEvents', vmId], fromJS(events)) + }, + [SAVE_EVENT_FILTERS] (state: any, { payload: { filters } }: any): any { + return state.setIn(['eventFilters'], fromJS(filters)) + }, + [SET_EVENT_SORT] (state: any, { payload: { sort } }: any): any { + return state.setIn(['eventSort'], fromJS(sort)) + }, + }) export default userMessages diff --git a/src/routes.js b/src/routes.js index f5154d31d..c327e275f 100644 --- a/src/routes.js +++ b/src/routes.js @@ -9,12 +9,14 @@ import { VmDetailToolbar, VmsListToolbar, SettingsToolbar, + EventsToolbar, } from './components/Toolbar' import { VmDetailsPage, VmsListPage, GlobalSettingsPage, VmConsolePage, + VmEventsPage, } from './components/Pages' import { @@ -23,6 +25,7 @@ import { CONSOLE_PAGE_TYPE, NO_REFRESH_TYPE, SETTINGS_PAGE_TYPE, + EVENTS_PAGE_TYPE, } from '_/constants' /** @@ -65,6 +68,16 @@ export default function getRoutes () { isToolbarFullWidth: true, type: CONSOLE_PAGE_TYPE, }, + { + path: '/vm/:id/events', + title: ({ msg }) => msg.events(), + component: VmEventsPage, + closeable: true, + // no toolbar + toolbars: (match) => , + isToolbarFullWidth: true, + type: EVENTS_PAGE_TYPE, + }, ], }, diff --git a/src/sagas/background-refresh.js b/src/sagas/background-refresh.js index 51d7408bd..e0024aaab 100644 --- a/src/sagas/background-refresh.js +++ b/src/sagas/background-refresh.js @@ -26,14 +26,17 @@ import { delay } from './utils' import { fetchAndPutSingleVm, fetchByPage, + fetchLastVmEvents, fetchPools, fetchSinglePool, fetchSingleVm, + fetchAllVmEvents, fetchVms, } from './index' import { getConsoleOptions } from './console' import { fetchIsoFiles } from './storageDomains' import { fetchUnknownIcons } from './osIcons' +import { toJS } from '_/helpers' const BACKGROUND_REFRESH = 'BACKGROUND_REFRESH' @@ -113,6 +116,7 @@ const pagesRefreshers = { [C.CREATE_PAGE_TYPE]: refreshCreatePage, [C.CONSOLE_PAGE_TYPE]: refreshConsolePage, [C.SETTINGS_PAGE_TYPE]: loadUserOptions, + [C.EVENTS_PAGE_TYPE]: refreshEventsPage, } function* refreshListPage () { @@ -207,9 +211,13 @@ function* refreshListPage () { } function* refreshDetailPage ({ id: vmId, manualRefresh }) { - yield fetchAndPutSingleVm(Actions.getSingleVm({ vmId })) + const { internalVm } = yield fetchAndPutSingleVm(Actions.getSingleVm({ vmId })) yield getConsoleOptions(Actions.getConsoleOptions({ vmId })) + if (internalVm?.name) { + yield fetchLastVmEvents(Actions.getVmEvents({ vmId, vmName: internalVm.name, maxItems: 2 })) + } + // TODO: If the VM is from a Pool, refresh the Pool as well. // Load ISO images on manual refresh click only @@ -235,6 +243,21 @@ function* refreshConsolePage ({ id: vmId }) { } } +function* refreshEventsPage ({ id: vmId }) { + if (!vmId) { + return + } + const { internalVm } = yield fetchAndPutSingleVm(Actions.getSingleVm({ vmId, shallowFetch: true })) + const [[newestEvent]] = yield select(({ userMessages }) => [ + toJS(userMessages.getIn(['events', vmId], [])), + ]) + + const vmName = internalVm?.name + if (vmName) { + yield fetchAllVmEvents(Actions.getVmEvents({ vmId, vmName, newestEventId: newestEvent?.id, maxItems: 500 })) + } +} + // // *** Scheduler/Timer Sagas *** // diff --git a/src/sagas/index.js b/src/sagas/index.js index d21251e84..5ca644285 100644 --- a/src/sagas/index.js +++ b/src/sagas/index.js @@ -21,6 +21,8 @@ import sagasVmChanges from './vmChanges' import sagasVmSnapshots from '_/components/VmDetails/cards/SnapshotsCard/sagas' import { + addLastVmEvents, + addVmEvents, updateVms, setVmSnapshots, @@ -379,6 +381,31 @@ function* clearEvents ({ payload: { records = [] } }) { yield fetchAllEvents() } +export function* fetchAllVmEvents (action) { + const { vmId } = action.payload + const { error, event: events } = yield callExternalAction(Api.eventsForVm, action) + + if (error || !Array.isArray(events)) { + yield put(addVmEvents({ events: [], vmId })) + return + } + + const internalEvents = events.map(event => Transforms.Event.toInternal({ event })) + yield put(addVmEvents({ events: internalEvents, vmId })) +} + +export function* fetchLastVmEvents (action) { + const { vmId } = action.payload + const { error, event: events } = yield callExternalAction(Api.eventsForVm, action) + + if (error || !Array.isArray(events)) { + return + } + + const internalEvents = events.map(event => Transforms.Event.toInternal({ event })) + yield put(addLastVmEvents({ events: internalEvents, vmId })) +} + export function* fetchVmSessions ({ vmId }) { const sessions = yield callExternalAction(Api.sessions, { payload: { vmId } })