From 1da925a7e3cfcdc6cfb5f62458184da26c7d91b6 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Mon, 6 Jun 2022 13:59:54 +0200 Subject: [PATCH 1/5] Extract toolbar filtering and sorting Before, filtering and sorting was tied to VM entities. After this patch the generic filter/sort component can be reused for other entities. Filter component changes: 1. create a new type of filter - a date filter 2. generalize name filter to text based filter - as before only one filter of this type is supported per filter toolbar 3. extract enum based filter as SelectFilter Sort component changes: 1. refactor SortFields from constant to parameter 2. drop unused isNumeric flag - natural sort order is used for all types of input --- src/components/Toolbar/DatePickerFilter.js | 78 +++++++++ src/components/Toolbar/Filters.js | 141 ++++++++++++++++ src/components/Toolbar/SelectFilter.js | 79 +++++++++ src/components/Toolbar/Sort.js | 83 ++++++++++ src/components/Toolbar/VmFilters.js | 179 +-------------------- src/components/Toolbar/VmSort.js | 71 +------- src/utils/vms-sort.js | 3 - 7 files changed, 393 insertions(+), 241 deletions(-) create mode 100644 src/components/Toolbar/DatePickerFilter.js create mode 100644 src/components/Toolbar/Filters.js create mode 100644 src/components/Toolbar/SelectFilter.js create mode 100644 src/components/Toolbar/Sort.js 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/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' }, }, } From ea9a10e75e7dc965ddc6a1a7f2d36f6301117bbc Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Wed, 18 May 2022 17:00:24 +0200 Subject: [PATCH 2/5] 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 | 87 +++++++++ src/components/Events/EventSort.js | 44 +++++ src/components/Events/EventStatus.js | 18 ++ src/components/Events/EventsTable.js | 181 ++++++++++++++++++ src/components/Events/index.js | 5 + src/components/Events/style.css | 3 + src/components/Pages/index.js | 11 +- src/components/Toolbar/index.js | 64 +++++++ .../VmDetails/cards/OverviewCard/index.js | 43 ++++- .../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 | 11 ++ src/reducers/userMessages.js | 28 +++ src/routes.js | 12 ++ src/sagas/background-refresh.js | 25 ++- src/sagas/index.js | 27 +++ 19 files changed, 657 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..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..f2e43f67a --- /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/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..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..6d84cfb54 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: var(--pf-global--FontWeight--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..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..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..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 } }) From 02908184541e99a2a32ab3e9490c7dd0bd1e1725 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Tue, 14 Jun 2022 20:06:32 +0200 Subject: [PATCH 3/5] Use ellipsis on text overflow for long event messages --- src/components/VmDetails/cards/OverviewCard/index.js | 8 ++++++-- src/components/VmDetails/cards/OverviewCard/style.css | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/VmDetails/cards/OverviewCard/index.js b/src/components/VmDetails/cards/OverviewCard/index.js index 275882bc0..724a306c0 100644 --- a/src/components/VmDetails/cards/OverviewCard/index.js +++ b/src/components/VmDetails/cards/OverviewCard/index.js @@ -337,8 +337,12 @@ class OverviewCard extends React.Component { { lastEvents.map(({ id, time, description, severity }) => ( {new Date(time).toLocaleString(locale)} {description}}> - - {description} + + + {description} + )) diff --git a/src/components/VmDetails/cards/OverviewCard/style.css b/src/components/VmDetails/cards/OverviewCard/style.css index 6d84cfb54..8ed825c56 100644 --- a/src/components/VmDetails/cards/OverviewCard/style.css +++ b/src/components/VmDetails/cards/OverviewCard/style.css @@ -11,17 +11,20 @@ } .event { + display: flex; + /* event text and time label have different height */ + align-items: center; +} + +.eventText { 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 { From 75bf4053c1f325da0e5ca1cc251d877890c52ea2 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Tue, 14 Jun 2022 20:54:09 +0200 Subject: [PATCH 4/5] Store events sorted with newest items first --- src/reducers/userMessages.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/reducers/userMessages.js b/src/reducers/userMessages.js index f3947938e..16c5357a8 100644 --- a/src/reducers/userMessages.js +++ b/src/reducers/userMessages.js @@ -16,7 +16,7 @@ import { 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' @@ -105,15 +105,16 @@ const userMessages: any = actionReducer(initialState, { }, [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))) + 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)) From ebd913f470c73d9dac7dca21f611c99004edbc5b Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Tue, 14 Jun 2022 21:32:48 +0200 Subject: [PATCH 5/5] Use breakWord mode for displaying event message column --- src/components/Events/EventsTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Events/EventsTable.js b/src/components/Events/EventsTable.js index f2e43f67a..39702c513 100644 --- a/src/components/Events/EventsTable.js +++ b/src/components/Events/EventsTable.js @@ -141,7 +141,7 @@ const EventsTable = ({ {new Date(time).toLocaleString(locale)} - + {description}