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..8e0d82617 --- /dev/null +++ b/src/components/Events/EventFilters.js @@ -0,0 +1,87 @@ +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 moment from 'moment' + +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, +} + +export function filterEvents ({ events, severityFilters, dateFilters, messageFilters }) { + return 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 + }) +} + +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..96a3c8c54 --- /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' + +export 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..f86a94326 --- /dev/null +++ b/src/components/Events/EventsTable.js @@ -0,0 +1,185 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import { withMsg } from '_/intl' + +import { + TableComposable, + Tbody, + Th, + Thead, + Td, + Tr, +} from '@patternfly/react-table' + +import { + Button, + EmptyState, + EmptyStateIcon, + EmptyStateBody, + Spinner, + Title, +} 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, translate } from '_/helpers' + +import { saveEventFilters, setEventSort } from '_/actions' + +import { SEVERITY, DATE, MESSAGE, EVENT_SEVERITY, UNKNOWN, filterEvents } from './EventFilters' + +import { SortFields } from './EventSort' + +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 = { id: DATE }, + clearAllFilters, + setSort, +}) => { + const columns = [ + SortFields[SEVERITY], + SortFields[DATE], + SortFields[MESSAGE], + ].map(({ messageDescriptor, ...rest }) => ({ + ...rest, + messageDescriptor, + label: messageDescriptor?.id ? translate({ id: messageDescriptor?.id, msg }) : '', + })) + + const activeSortIndex = columns.findIndex(({ id }) => id === eventSort.id) + const activeSortDirection = eventSort.isAsc ? 'asc' : 'desc' + const buildSort = (columnIndex) => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + }, + onSort: (_event, index, direction) => { + setSort({ + isAsc: direction === 'asc', + ...SortFields?.[columns[index]?.id ?? SEVERITY], + }) + }, + columnIndex, + }) + + const filteredEvents = filterEvents({ events, severityFilters, dateFilters, messageFilters }) + + sortEvents(filteredEvents, eventSort, locale) + + return ( +
+ { !filteredEvents && ( + + + + ) } + + { filteredEvents?.length === 0 && ( + + + + {msg.noEventsFound()} + + {msg.clearAllFiltersAndTryAgain()} + + + ) } + + { filteredEvents?.length > 0 && ( + <> + + + + {columns.map(({ label }, index) => ( + + {label} + + ))} + + + + {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, + setSort: 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: {} })), + setSort: (sort) => dispatch(setEventSort({ sort })), + }) +)(withMsg(EventsTable)) diff --git a/src/components/Events/index.js b/src/components/Events/index.js new file mode 100644 index 000000000..99b75352d --- /dev/null +++ b/src/components/Events/index.js @@ -0,0 +1,5 @@ +export { default as EventsTable } from './EventsTable' +export * from './EventStatus' +export * from './EventFilters' +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..b53e634a5 --- /dev/null +++ b/src/components/Events/style.css @@ -0,0 +1,3 @@ +:global(#page-router-render-component) .container { + padding-top: 0; +} 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..5cd6f648d 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -6,13 +6,25 @@ import { Toolbar, ToolbarContent, ToolbarGroup, + ToolbarItem, } from '@patternfly/react-core' import { RouterPropTypeShapes } from '_/propTypeShapes' import VmActions from '../VmActions' import VmConsoleSelector from '../VmConsole/VmConsoleSelector' import VmConsoleInstructionsModal from '../VmConsole/VmConsoleInstructionsModal' import VmsListToolbar from './VmsListToolbar' +import { + SEVERITY, + DATE, + MESSAGE, + filterEvents, + EventFilters, + EventSort, +} from '../Events' import { NATIVE_VNC, SPICE } from '_/constants' +import { saveEventFilters } from '_/actions' +import { withMsg } from '_/intl' +import { toJS } from '_/helpers' const VmDetailToolbar = ({ match, vms }) => { if (vms.getIn(['vms', match.params.id])) { @@ -43,6 +55,54 @@ const VmDetailToolbarConnected = connect( }) )(VmDetailToolbar) +const EventsToolbar = ({ + events, + eventFilters: { [SEVERITY]: severityFilters, [DATE]: dateFilters, [MESSAGE]: messageFilters }, + msg, + onClearFilters, +}) => { + if (!events) { + return null + } + const total = events?.length ?? 0 + const hasFilters = severityFilters?.length || dateFilters?.length || messageFilters?.length + const filteredEvents = filterEvents({ events, severityFilters, dateFilters, messageFilters }) + return ( + + + + + +
+ { hasFilters + ? msg.resultsOf({ total, available: filteredEvents?.length ?? 0 }) + : msg.results({ total }) + } +
+
+
+
+ ) +} + +EventsToolbar.propTypes = { + events: PropTypes.array, + eventFilters: PropTypes.object.isRequired, + msg: PropTypes.object.isRequired, + + onClearFilters: PropTypes.func.isRequired, +} + +const EventsToolbarConnected = connect( + ({ userMessages }, { match }) => ({ + events: toJS(userMessages.getIn(['events', match?.params?.id])), + eventFilters: toJS(userMessages.getIn(['eventFilters'], {})), + }), + (dispatch) => ({ + onClearFilters: () => dispatch(saveEventFilters({ filters: {} })), + }) +)(withMsg(EventsToolbar)) + const VmConsoleToolbar = ({ match: { params: { id, consoleType } } = {}, vms }) => { if (!vms.getIn(['vms', id])) { return @@ -84,7 +144,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..275882bc0 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,12 +18,19 @@ 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' +import itemListStyle from '../../itemListStyle.css' /** * Overview of the VM (icon, OS type, name, state, description) @@ -196,7 +205,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 +241,7 @@ class OverviewCard extends React.Component { > {({ isEditing }) => { return ( -
+ <>
{getOsHumanName(vm.getIn(['os', 'type']))}
@@ -318,7 +327,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 +364,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 +381,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..dac50d7c7 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', 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..850aae473 100644 --- a/src/ovirtapi/index.js +++ b/src/ovirtapi/index.js @@ -305,6 +305,17 @@ const OvirtApi = { assertLogin({ methodName: 'dismissEvent' }) return httpDelete({ url: `${AppConfiguration.applicationContext}/api/events/${eventId}` }) }, + eventsForVm ({ vmName, newestEventId = 0, maxItems = 0 }: Object): Promise { + // TODO generic search is expensive: extend REST API capability to fetch directly using VM ID + 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 } })