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..39702c513 --- /dev/null +++ b/src/components/Events/EventsTable.js @@ -0,0 +1,181 @@ +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 }) => ( + + + + + + {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/DatePickerFilter.js b/src/components/Toolbar/DatePickerFilter.js new file mode 100644 index 000000000..116661e5a --- /dev/null +++ b/src/components/Toolbar/DatePickerFilter.js @@ -0,0 +1,78 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { withMsg } from '_/intl' +import { + DatePicker, + InputGroup, + ToolbarFilter, +} from '@patternfly/react-core' + +import moment from 'moment' + +const DatePickerFilter = ({ + filterId, + selectedFilters, + showToolbarItem, + title, + onFilterUpdate, + msg, +}) => { + const dateFormat = moment.localeData().longDateFormat('L') + const formatDate = date => moment(date).format(dateFormat) + const parseDate = str => moment(str, dateFormat).toDate() + const isValidDate = date => moment(date, dateFormat).isValid() + const toISO = str => moment(str, dateFormat).format('YYYY-MM-DD') + const fromISO = str => moment(str, 'YYYY-MM-DD').format(dateFormat) + + const [date, setDate] = useState(toISO(formatDate(Date.now()))) + + const clearSingleDate = (option) => { + console.warn('clearSingle ', option) + const fixed = toISO(option) + onFilterUpdate([...selectedFilters?.filter(d => d !== fixed)]) + } + + const onDateChange = (inputDate, newDate) => { + if (isValidDate(inputDate)) { + const fixed = toISO(inputDate) + setDate(fixed) + onFilterUpdate([...selectedFilters?.filter(d => d !== fixed), fixed]) + } + } + + return ( + clearSingleDate(option)} + deleteChipGroup={() => onFilterUpdate([])} + categoryName={title} + showToolbarItem={showToolbarItem} + > + + + + + ) +} + +DatePickerFilter.propTypes = { + filterId: PropTypes.string.isRequired, + selectedFilters: PropTypes.array.isRequired, + showToolbarItem: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + onFilterUpdate: PropTypes.func.isRequired, + + msg: PropTypes.object.isRequired, +} + +export default withMsg(DatePickerFilter) diff --git a/src/components/Toolbar/Filters.js b/src/components/Toolbar/Filters.js new file mode 100644 index 000000000..f8b108fdf --- /dev/null +++ b/src/components/Toolbar/Filters.js @@ -0,0 +1,141 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { withMsg } from '_/intl' +import { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownToggle, + InputGroup, + TextInput, + ToolbarGroup, + ToolbarFilter, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core' + +import { FilterIcon, SearchIcon } from '@patternfly/react-icons/dist/esm/icons' + +import DatePickerFilter from './DatePickerFilter' +import SelectFilter from './SelectFilter' + +const Filters = ({ msg, locale, selectedFilters, onFilterUpdate, filterTypes, textBasedFilterId }) => { + const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]) + const [expanded, setExpanded] = useState(false) + const [inputValue, setInputValue] = useState('') + + const nameFilter = filterTypes.find(({ id }) => id === textBasedFilterId) + const labelToFilter = (label) => filterTypes.find(({ title }) => title === label) ?? currentFilterType + + const onFilterTypeSelect = (event) => { + setCurrentFilterType(labelToFilter(event?.target?.innerText)) + setExpanded(!expanded) + } + const onFilterTypeToggle = () => setExpanded(!expanded) + const onNameInput = (event) => { + if ((event.key && event.key !== 'Enter') || + !inputValue || + selectedFilters?.[textBasedFilterId]?.includes(inputValue)) { + return + } + onFilterUpdate({ ...selectedFilters, [textBasedFilterId]: [...(selectedFilters?.[textBasedFilterId] ?? []), inputValue] }) + setInputValue('') + } + + return ( + } breakpoint="xl"> + + + + {currentFilterType.title} + + )} + isOpen={expanded} + style={{ width: '100%' }} + dropdownItems={ + filterTypes.map(({ id, title }) => + {title}) + } + /> + + onFilterUpdate({ + ...selectedFilters, + [textBasedFilterId]: selectedFilters?.[textBasedFilterId]?.filter?.(value => value !== option) ?? [], + })} + deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [textBasedFilterId]: [] })} + categoryName={nameFilter.title} + showToolbarItem={currentFilterType.id === textBasedFilterId} + > + + + + + + { + filterTypes.filter(({ datePicker }) => datePicker).map(({ id: filterId, title }) => ( + { + console.warn('filtersToSave', filtersToSave) + onFilterUpdate({ ...selectedFilters, [filterId]: filtersToSave }) + } + } + /> + )) + } + {filterTypes.filter(({ filterValues }) => !!filterValues?.length) + ?.map(({ id, filterValues, placeholder, title }) => ( + onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })} + title={title} + placeholderText={placeholder} + /> + ) + )} + + + ) +} + +Filters.propTypes = { + selectedFilters: PropTypes.object.isRequired, + filterTypes: PropTypes.array.isRequired, + textBasedFilterId: PropTypes.string.isRequired, + onFilterUpdate: PropTypes.func.isRequired, + msg: PropTypes.object.isRequired, + locale: PropTypes.string.isRequired, +} + +export default withMsg(Filters) diff --git a/src/components/Toolbar/SelectFilter.js b/src/components/Toolbar/SelectFilter.js new file mode 100644 index 000000000..7819de314 --- /dev/null +++ b/src/components/Toolbar/SelectFilter.js @@ -0,0 +1,79 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { + Select, + SelectOption, + SelectVariant, + ToolbarFilter, +} from '@patternfly/react-core' + +const SelectFilter = ({ filterIds = [], setFilters, allSupportedFilters = [], title, placeholderText, filterColumnId, showToolbarItem }) => { + const [isExpanded, setExpanded] = useState(false) + + // one label can map to many IDs so it's easier work with labels + // and reverse map label-to-IDs on save + const toChip = ({ title }) => title + const toOption = ({ title }) => title + const toOptionNode = ({ title }) => + + + // titles are guaranteed to be unique + // return first filter with matching title + const labelToIds = (title) => { + const [{ ids = {} } = {}] = allSupportedFilters.filter(filter => filter.title === title) || [] + return ids + } + const selectedFilters = allSupportedFilters.filter(({ ids }) => filterIds.find(id => ids[id])) + const deleteFilter = (title) => { + const ids = labelToIds(title) + // delete all filter IDs linked to provided title + setFilters(filterIds.filter(id => !ids[id])) + } + + const addFilter = (title) => { + const ids = labelToIds(title) + // add all filter IDs linked + setFilters([...filterIds, ...Object.keys(ids)]) + } + return ( + deleteFilter(option)} + deleteChipGroup={() => setFilters([])} + categoryName={title} + showToolbarItem={showToolbarItem} + > + + + ) +} + +SelectFilter.propTypes = { + filterIds: PropTypes.array.isRequired, + allSupportedFilters: PropTypes.array.isRequired, + setFilters: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + placeholderText: PropTypes.string.isRequired, + filterColumnId: PropTypes.string.isRequired, + showToolbarItem: PropTypes.bool.isRequired, +} + +export default SelectFilter diff --git a/src/components/Toolbar/Sort.js b/src/components/Toolbar/Sort.js new file mode 100644 index 000000000..8c7961710 --- /dev/null +++ b/src/components/Toolbar/Sort.js @@ -0,0 +1,83 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import { withMsg } from '_/intl' +import { translate } from '_/helpers' +import { + Button, + OptionsMenu, + OptionsMenuItemGroup, + OptionsMenuSeparator, + OptionsMenuItem, + OptionsMenuToggle, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core' +import { SortAmountDownIcon, SortAmountDownAltIcon } from '@patternfly/react-icons/dist/esm/icons' + +const Sort = ({ sort, msg, onSortChange, SortFields }) => { + const { id: enabledSortId, isAsc } = sort || {} + const [expanded, setExpanded] = useState(false) + + const menuItems = [ + + {Object.values(SortFields) + .map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) })) + .map(({ title, id, messageDescriptor }) => ( + onSortChange({ ...sort, id, messageDescriptor })} + > + {title} + + )) + } + , + , + + onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()} + onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()} + , + ] + + return ( + + + setExpanded(!expanded)} + toggleTemplate={sort?.messageDescriptor ? translate({ ...sort.messageDescriptor, msg }) : msg.sortBy()} + /> + )} + isGrouped + /> + + + + + + ) +} + +Sort.propTypes = { + sort: PropTypes.shape({ + id: PropTypes.string.isRequired, + messageDescriptor: PropTypes.object.isRequired, + isAsc: PropTypes.bool, + }), + SortFields: PropTypes.objectOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + messageDescriptor: PropTypes.object.isRequired, + })).isRequired, + onSortChange: PropTypes.func.isRequired, + msg: PropTypes.object.isRequired, +} + +export default withMsg(Sort) diff --git a/src/components/Toolbar/VmFilters.js b/src/components/Toolbar/VmFilters.js index 383dd7d48..1f9116cdf 100644 --- a/src/components/Toolbar/VmFilters.js +++ b/src/components/Toolbar/VmFilters.js @@ -1,28 +1,11 @@ -import React, { useState, useMemo } from 'react' +import React, { useMemo } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import { enumMsg, withMsg } from '_/intl' import { saveVmsFilters } from '_/actions' import { localeCompare, toJS } from '_/helpers' -import { - Button, - ButtonVariant, - Dropdown, - DropdownItem, - DropdownPosition, - DropdownToggle, - InputGroup, - Select, - SelectOption, - SelectVariant, - TextInput, - ToolbarGroup, - ToolbarFilter, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core' -import { FilterIcon, SearchIcon } from '@patternfly/react-icons/dist/esm/icons' +import Filters from './Filters' const STATUS = 'status' const OS = 'os' @@ -79,74 +62,6 @@ const composeOs = (msg, locale, operatingSystems) => { }) } -const Filter = ({ filterIds = [], setFilters, allSupportedFilters = [], title, filterColumnId, showToolbarItem }) => { - const [isExpanded, setExpanded] = useState(false) - - // one label can map to many IDs so it's easier work with labels - // and reverse map label-to-IDs on save - const toChip = ({ title }) => title - const toOption = ({ title }) => title - const toOptionNode = ({ title }) => - - - // titles are guaranteed to be unique - // return first filter with matching title - const labelToIds = (title) => { - const [{ ids = {} } = {}] = allSupportedFilters.filter(filter => filter.title === title) || [] - return ids - } - const selectedFilters = allSupportedFilters.filter(({ ids }) => filterIds.find(id => ids[id])) - const deleteFilter = (title) => { - const ids = labelToIds(title) - // delete all filter IDs linked to provided title - setFilters(filterIds.filter(id => !ids[id])) - } - - const addFilter = (title) => { - const ids = labelToIds(title) - // add all filter IDs linked - setFilters([...filterIds, ...Object.keys(ids)]) - } - return ( - deleteFilter(option)} - deleteChipGroup={() => setFilters([])} - categoryName={filterColumnId} - showToolbarItem={showToolbarItem} - > - - - ) -} - -Filter.propTypes = { - filterIds: PropTypes.array.isRequired, - allSupportedFilters: PropTypes.array.isRequired, - setFilters: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, - filterColumnId: PropTypes.string.isRequired, - showToolbarItem: PropTypes.bool.isRequired, -} - const VmFilters = ({ msg, locale, operatingSystems, selectedFilters, onFilterUpdate }) => { const filterTypes = useMemo(() => [ { @@ -157,91 +72,13 @@ const VmFilters = ({ msg, locale, operatingSystems, selectedFilters, onFilterUpd composeStatus(msg, locale), composeOs(msg, locale, operatingSystems), ], [msg, locale, operatingSystems]) - const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]) - const [expanded, setExpanded] = useState(false) - const [inputValue, setInputValue] = useState('') - - const nameFilter = filterTypes.find(({ id }) => id === NAME) - const labelToFilter = (label) => filterTypes.find(({ title }) => title === label) ?? currentFilterType - - const onFilterTypeSelect = (event) => { - setCurrentFilterType(labelToFilter(event?.target?.innerText)) - setExpanded(!expanded) - } - const onFilterTypeToggle = () => setExpanded(!expanded) - const onNameInput = (event) => { - if ((event.key && event.key !== 'Enter') || - !inputValue || - selectedFilters?.[NAME]?.includes(inputValue)) { - return - } - onFilterUpdate({ ...selectedFilters, [NAME]: [...(selectedFilters?.[NAME] ?? []), inputValue] }) - setInputValue('') - } - return ( - } breakpoint="xl"> - - - - {currentFilterType.title} - - )} - isOpen={expanded} - style={{ width: '100%' }} - dropdownItems={ - filterTypes.map(({ id, title }) => - {title}) - } - /> - - onFilterUpdate({ - ...selectedFilters, - [NAME]: selectedFilters?.[NAME]?.filter?.(value => value !== option) ?? [], - })} - deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [NAME]: [] })} - categoryName={NAME} - showToolbarItem={currentFilterType.id === NAME} - > - - - - - - {filterTypes.filter(({ id }) => id !== NAME)?.map(({ id, filterValues, placeholder }) => ( - onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })} - title={placeholder} - /> - ) - )} - - + ) } diff --git a/src/components/Toolbar/VmSort.js b/src/components/Toolbar/VmSort.js index 7f0d8cc1a..6ec0e6467 100644 --- a/src/components/Toolbar/VmSort.js +++ b/src/components/Toolbar/VmSort.js @@ -1,83 +1,20 @@ -import React, { useState } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' - -import { setVmSort } from '_/actions' +import Sort from './Sort' import { SortFields } from '_/utils' +import { setVmSort } from '_/actions' import { withMsg } from '_/intl' -import { translate } from '_/helpers' -import { - Button, - OptionsMenu, - OptionsMenuItemGroup, - OptionsMenuSeparator, - OptionsMenuItem, - OptionsMenuToggle, - ToolbarGroup, - ToolbarItem, -} from '@patternfly/react-core' -import { SortAmountDownIcon, SortAmountDownAltIcon } from '@patternfly/react-icons/dist/esm/icons' -const VmSort = ({ sort, msg, onSortChange }) => { - const { id: enabledSortId, isAsc } = sort - const [expanded, setExpanded] = useState(false) - - const menuItems = [ - - {Object.values(SortFields) - .map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) })) - .map(({ title, id, messageDescriptor }) => ( - onSortChange({ ...sort, id, messageDescriptor })} - > - {title} - - )) - } - , - , - - onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()} - onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()} - , - ] - - return ( - - - setExpanded(!expanded)} - toggleTemplate={sort?.messageDescriptor ? translate({ ...sort.messageDescriptor, msg }) : msg.sortBy()} - /> - )} - isGrouped - /> - - - - - - ) -} +const VmSort = ({ sort, onSortChange }) => VmSort.propTypes = { sort: PropTypes.shape({ id: PropTypes.string.isRequired, messageDescriptor: PropTypes.object.isRequired, - isNumeric: PropTypes.bool, isAsc: PropTypes.bool, }).isRequired, onSortChange: PropTypes.func.isRequired, - msg: PropTypes.object.isRequired, } export default connect( diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js index 580d45c08..ced42fe8f 100644 --- a/src/components/Toolbar/index.js +++ b/src/components/Toolbar/index.js @@ -6,10 +6,22 @@ import { Toolbar, ToolbarContent, ToolbarGroup, + ToolbarItem, } from '@patternfly/react-core' import { RouterPropTypeShapes } from '_/propTypeShapes' import VmActions from '../VmActions' import VmsListToolbar from './VmsListToolbar' +import { + SEVERITY, + DATE, + MESSAGE, + filterEvents, + EventFilters, + EventSort, +} from '../Events' +import { saveEventFilters } from '_/actions' +import { withMsg } from '_/intl' +import { toJS } from '_/helpers' const VmDetailToolbar = ({ match, vms }) => { if (vms.getIn(['vms', match.params.id])) { @@ -40,10 +52,62 @@ 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 SettingsToolbar = () =>
export { VmDetailToolbarConnected as VmDetailToolbar, + EventsToolbarConnected as EventsToolbar, 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..724a306c0 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,30 @@ 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 +368,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 +385,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..8ed825c56 100644 --- a/src/components/VmDetails/cards/OverviewCard/style.css +++ b/src/components/VmDetails/cards/OverviewCard/style.css @@ -2,6 +2,31 @@ * Styles for the Overview Card */ +.bold { + font-weight: var(--pf-global--FontWeight--bold); + } + + .fullWidth { + width: 100%; +} + +.event { + display: flex; + /* event text and time label have different height */ + align-items: center; +} + +.eventText { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + + +.event :global(.pf-c-label) { + margin-right: 5px; +} + .operating-system-label { position: absolute; top: 0; @@ -11,7 +36,9 @@ .vm-info { padding: 15px; - flex-grow: 1 + flex-grow: 1; + display: flex; + flex-direction: column; } .vm-name { @@ -57,13 +84,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..48535fb57 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..16c5357a8 100644 --- a/src/reducers/userMessages.js +++ b/src/reducers/userMessages.js @@ -1,17 +1,22 @@ // @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' -import { toJS } from '_/helpers' +import { localeCompare, toJS } from '_/helpers' import uniqueId from 'lodash/uniqueId' import type { FailedExternalActionType } from '_/actions/types' @@ -38,6 +43,8 @@ function removeEvents (targetIds: Set, state: any): any { const initialState = Immutable.fromJS({ records: [], + events: {}, + lastEvents: {}, autoAcknowledge: false, }) @@ -97,6 +104,28 @@ const userMessages: any = actionReducer(initialState, { return state.set('autoAcknowledge', autoAcknowledge) }, + [ADD_VM_EVENTS] (state: any, { payload: { events, vmId } }: any): any { + const existingEvents = toJS(state.getIn(['events', vmId], [])) + const existingIds = new Set(existingEvents.map(({ id }) => id)) + const filteredEvents = events + // keep events unique + .filter(({ id, ...rest }) => !existingIds[id]) + // keep events sorted in descending order + .sort((a, b) => localeCompare(b?.id ?? '', a?.id ?? '', 'en')) + + // newest first + return state.setIn(['events', vmId], fromJS([...filteredEvents, ...existingEvents])) + }, + [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..dfc8f7497 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,15 @@ export default function getRoutes () { isToolbarFullWidth: true, type: CONSOLE_PAGE_TYPE, }, + { + path: '/vm/:id/events', + title: ({ msg }) => msg.events(), + component: VmEventsPage, + closeable: true, + 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 } }) diff --git a/src/utils/vms-sort.js b/src/utils/vms-sort.js index b683a07ae..eba1f6c36 100644 --- a/src/utils/vms-sort.js +++ b/src/utils/vms-sort.js @@ -11,17 +11,14 @@ const getFieldValueMap = (msg) => ({ export const SortFields = { NAME: { id: 'name', - isNumeric: false, messageDescriptor: { id: 'name' }, }, OS: { id: 'os', - isNumeric: false, messageDescriptor: { id: 'operatingSystem' }, }, STATUS: { id: 'status', - isNumeric: false, messageDescriptor: { id: 'status' }, }, }