+ + + +
- +
`; diff --git a/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx b/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx index 71de85287..65d54c93c 100644 --- a/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx +++ b/src/app/devices/deviceEvents/components/deviceEvents.spec.tsx @@ -6,100 +6,289 @@ import 'jest'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; -const InfiniteScroll = require('react-infinite-scroller'); // tslint:disable-line: no-var-requires import { TextField } from 'office-ui-fabric-react/lib/components/TextField'; import { Toggle } from 'office-ui-fabric-react/lib/components/Toggle'; import { CommandBar } from 'office-ui-fabric-react/lib/components/CommandBar'; +import { Shimmer } from 'office-ui-fabric-react/lib/components/Shimmer'; import { DeviceEvents } from './deviceEvents'; import { appConfig, HostMode } from '../../../../appConfig/appConfig'; -import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants'; -import { MILLISECONDS_IN_MINUTE } from '../../../constants/shared'; +import { startEventsMonitoringAction } from '../actions'; +import * as AsyncSagaReducer from '../../../shared/hooks/useAsyncSagaReducer'; +import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; +import * as pnpStateContext from '../../../shared/contexts/pnpStateContext'; +import { pnpStateInitial, PnpStateInterface } from '../../pnp/state'; +import { testModelDefinition } from '../../pnp/components/deviceEvents/testData'; +import { REPOSITORY_LOCATION_TYPE } from '../../../constants/repositoryLocationTypes'; +import { ErrorBoundary } from '../../shared/components/errorBoundary'; +import { ResourceKeys } from '../../../../localization/resourceKeys'; const pathname = `#/devices/detail/events/?id=device1`; +const currentTime = new Date(); jest.mock('react-router-dom', () => ({ - useLocation: () => ({ search: `?id=device1`, pathname }), + useHistory: () => ({ push: jest.fn() }), + useLocation: () => ({ search: `?deviceId=device1`, pathname, push: jest.fn() }) })); -describe('components/devices/deviceEvents', () => { - it('matches snapshot in electron', () => { - appConfig.hostMode = HostMode.Electron; - expect(shallow()).toMatchSnapshot(); - }); - - it('matches snapshot in hosted environment', () => { - appConfig.hostMode = HostMode.Browser; - expect(shallow()).toMatchSnapshot(); - }); - - it('changes state accordingly when command bar buttons are clicked', () => { - const wrapper = mount(); - const commandBar = wrapper.find(CommandBar).first(); - // click the start button - act(() => commandBar.props().items[0].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().hasMore).toBeTruthy(); - - // click the start button again which has been toggled to stop button - act(() => wrapper.find(CommandBar).first().props().items[0].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().hasMore).toBeFalsy(); - - // clear events button should be disabled - expect(commandBar.props().items[1].disabled).toBeTruthy(); - - // click the show system property button - expect(commandBar.props().items[2].iconProps.iconName).toEqual('Checkbox'); - act(() => commandBar.props().items[2].onClick(null)); // tslint:disable-line:no-magic-numbers - wrapper.update(); - const updatedCommandBar = wrapper.find(CommandBar).first(); - expect(updatedCommandBar.props().items[2].iconProps.iconName).toEqual('CheckboxComposite'); - }); - - it('changes state accordingly when consumer group value is changed', () => { - const wrapper = mount(); - const textField = wrapper.find(TextField).first(); - act(() => textField.instance().props.onChange({ target: null}, 'testGroup')); - wrapper.update(); - expect(wrapper.find(TextField).first().props().value).toEqual('testGroup'); - }); - - it('changes state accordingly when custom event hub boolean value is changed', () => { - const wrapper = mount(); - expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(0); - const toggle = wrapper.find(Toggle).at(0); - act(() => toggle.instance().props.onChange({ target: null}, false)); - wrapper.update(); - expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(6); - }); - - it('renders events', () => { +describe('deviceEvents', () => { + describe('deviceEvents in non-pnp context', () => { const events = [{ body: { humid: 123 }, enqueuedTime: '2019-10-14T21:44:58.397Z', properties: { - 'iothub-message-schema': 'humid' + 'iothub-message-schema': 'humid' } }]; + beforeEach(() => { + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + const realUseState = React.useState; + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(currentTime)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('matches snapshot in electron', () => { + appConfig.hostMode = HostMode.Electron; + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot in hosted environment', () => { + appConfig.hostMode = HostMode.Browser; + expect(shallow()).toMatchSnapshot(); + }); + + it('changes state accordingly when command bar buttons are clicked', () => { + const wrapper = mount(); + const commandBar = wrapper.find(CommandBar).first(); + // tslint:disable-next-line: no-magic-numbers + expect(commandBar.props().items.length).toEqual(3); + + // click the start button + const startEventsMonitoringSpy = jest.spyOn(startEventsMonitoringAction, 'started'); + act(() => commandBar.props().items[0].onClick(null)); + wrapper.update(); + expect(startEventsMonitoringSpy.mock.calls[0][0]).toEqual({ + consumerGroup: DEFAULT_CONSUMER_GROUP, + deviceId: 'device1', + startTime: currentTime + }); + + // click the show system property button + expect(commandBar.props().items[1].iconProps.iconName).toEqual('Checkbox'); + act(() => commandBar.props().items[1].onClick(null)); // tslint:disable-line:no-magic-numbers + wrapper.update(); + const updatedCommandBar = wrapper.find(CommandBar).first(); + expect(updatedCommandBar.props().items[1].iconProps.iconName).toEqual('CheckboxComposite'); + }); + + it('changes state accordingly when consumer group value is changed', () => { + const wrapper = mount(); + const textField = wrapper.find(TextField).first(); + act(() => textField.instance().props.onChange({ target: null}, 'testGroup')); + wrapper.update(); + expect(wrapper.find(TextField).first().props().value).toEqual('testGroup'); + }); + + it('changes state accordingly when custom event hub boolean value is changed', () => { + const wrapper = mount(); + expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(0); + const toggle = wrapper.find(Toggle).at(0); + act(() => toggle.instance().props.onChange({ target: null}, false)); + wrapper.update(); + // tslint:disable-next-line: no-magic-numbers + expect(wrapper.find('.custom-event-hub-text-field').length).toEqual(6); + }); + + it('renders events', () => { + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({ pnpState: pnpStateInitial(), dispatch: jest.fn()}); + const wrapper = mount(); + const rawTelemetry = wrapper.find('article'); + expect(rawTelemetry).toHaveLength(1); + }); + }); + + describe('deviceEvents in pnp context', () => { + const getModelDefinitionMock = jest.fn(); + + const mockFetchedState = () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + payload: { + isModelValid: true, + modelDefinition: testModelDefinition, + source: REPOSITORY_LOCATION_TYPE.Public, + }, + synchronizationStatus: SynchronizationStatus.fetched + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({pnpState, dispatch: jest.fn(), getModelDefinition: getModelDefinitionMock}); + }; + + beforeEach(() => { + const realUseState = React.useState; + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(currentTime)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false)); + jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true)); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('renders Shimmer while loading', () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + synchronizationStatus: SynchronizationStatus.working + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); + const wrapper = mount(); + expect(wrapper.find(Shimmer)).toBeDefined(); + }); + + it('matches snapshot while interface cannot be found', () => { + const pnpState: PnpStateInterface = { + ...pnpStateInitial(), + modelDefinitionWithSource: { + payload: null, + synchronizationStatus: SynchronizationStatus.fetched + } + }; + jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot while interface definition is retrieved in electron', () => { + appConfig.hostMode = HostMode.Electron; + mockFetchedState(); + expect(shallow()).toMatchSnapshot(); + }); + + it('matches snapshot while interface definition is retrieved in hosted environment', () => { + appConfig.hostMode = HostMode.Browser; + mockFetchedState(); + expect(shallow()).toMatchSnapshot(); + }); + + it('renders events which body\'s value type is wrong with expected columns', () => { + const events = [{ + body: { + humid: '123' // intentionally set a value which type is double + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = mount(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props['aria-label']).toEqual(ResourceKeys.deviceEvents.columns.validation.value.label); // tslint:disable-line:no-magic-numbers + }); + + it('renders events which body\'s key name is wrong with expected columns', () => { + const events = [{ + body: { + 'non-matching-key': 0 + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = mount(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers + }); + + it('renders events when body is exploded and schema is not provided in system properties', () => { + const events = [{ + body: { + 'humid': 0, + 'humid-foo': 'test' + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: {} + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); + + const wrapper = shallow(); + let errorBoundary = wrapper.find(ErrorBoundary).first(); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({humid: 0}, undefined, 2)); // tslint:disable-line:no-magic-numbers + + errorBoundary = wrapper.find(ErrorBoundary).at(1); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({'humid-foo': 'test'}, undefined, 2)); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers + }); + + it('renders events which body\'s key name is not populated', () => { + const events = [{ + body: { + 'non-matching-key': 0 + }, + enqueuedTime: '2019-10-14T21:44:58.397Z' + }]; + jest.spyOn(AsyncSagaReducer, 'useAsyncSagaReducer').mockReturnValue([{ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }, jest.fn()]); + mockFetchedState(); - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - - const wrapper = mount(); - const enqueueTime = wrapper.find('h5'); - // tslint:disable-next-line:no-any - expect((enqueueTime.props().children as any).join('')).toBeDefined(); - - // click the clear events button - expect(wrapper.find(InfiniteScroll).first().props().role).toEqual('feed'); - const commandBar = wrapper.find(CommandBar).first(); - act(() => commandBar.props().items[1].onClick(null)); - wrapper.update(); - expect(wrapper.find(InfiniteScroll).first().props().role).toEqual('main'); + const wrapper = shallow(); + const errorBoundary = wrapper.find(ErrorBoundary); + expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); + expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers + expect(errorBoundary.children().at(4).props().children.props.children).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers + }); }); }); diff --git a/src/app/devices/deviceEvents/components/deviceEvents.tsx b/src/app/devices/deviceEvents/components/deviceEvents.tsx index 00b3a88a2..472bd8c49 100644 --- a/src/app/devices/deviceEvents/components/deviceEvents.tsx +++ b/src/app/devices/deviceEvents/components/deviceEvents.tsx @@ -6,76 +6,133 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/components/CommandBar'; import { Spinner } from 'office-ui-fabric-react/lib/components/Spinner'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useHistory } from 'react-router-dom'; import { TextField, ITextFieldProps } from 'office-ui-fabric-react/lib/components/TextField'; import { Announced } from 'office-ui-fabric-react/lib/components/Announced'; import { Toggle } from 'office-ui-fabric-react/lib/components/Toggle'; +import { Label } from 'office-ui-fabric-react/lib/components/Label'; import { ResourceKeys } from '../../../../localization/resourceKeys'; -import { monitorEvents, stopMonitoringEvents } from '../../../api/services/devicesService'; -import { Message } from '../../../api/models/messages'; +import { Message, MESSAGE_SYSTEM_PROPERTIES, MESSAGE_PROPERTIES } from '../../../api/models/messages'; import { parseDateTimeString } from '../../../api/dataTransforms/transformHelper'; -import { CLEAR, CHECKED_CHECKBOX, EMPTY_CHECKBOX, START, STOP } from '../../../constants/iconNames'; -import { getDeviceIdFromQueryString } from '../../../shared/utils/queryStringHelper'; +import { CLEAR, CHECKED_CHECKBOX, EMPTY_CHECKBOX, START, STOP, NAVIGATE_BACK, REFRESH, REMOVE } from '../../../constants/iconNames'; +import { getDeviceIdFromQueryString, getComponentNameFromQueryString, getInterfaceIdFromQueryString } from '../../../shared/utils/queryStringHelper'; import { SynchronizationStatus } from '../../../api/models/synchronizationStatus'; import { MonitorEventsParameters } from '../../../api/parameters/deviceParameters'; -import { NotificationType } from '../../../api/models/notification'; import { LabelWithTooltip } from '../../../shared/components/labelWithTooltip'; import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants'; import { MILLISECONDS_IN_MINUTE } from '../../../constants/shared'; import { appConfig, HostMode } from '../../../../appConfig/appConfig'; import { HeaderView } from '../../../shared/components/headerView'; import { isValidEventHubConnectionString } from '../../../shared/utils/hubConnectionStringHelper'; -import { raiseNotificationToast } from '../../../notifications/components/notificationToast'; +import { useAsyncSagaReducer } from '../../../shared/hooks/useAsyncSagaReducer'; +import { deviceEventsReducer } from '../reducers'; +import { EventMonitoringSaga } from '../saga'; +import { deviceEventsStateInitial } from '../state'; +import { startEventsMonitoringAction, stopEventsMonitoringAction, clearMonitoringEventsAction } from './../actions'; +import { DEFAULT_COMPONENT_FOR_DIGITAL_TWIN } from '../../../constants/devices'; +import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext'; +import { getDeviceTelemetry, TelemetrySchema } from '../../pnp/components/deviceEvents/dataHelper'; +import { ROUTE_PARAMS } from '../../../constants/routes'; +import { MultiLineShimmer } from '../../../shared/components/multiLineShimmer'; +import { ErrorBoundary } from '../../shared/components/errorBoundary'; +import { SemanticUnit } from '../../../shared/units/components/semanticUnit'; +import { getSchemaValidationErrors } from '../../../shared/utils/jsonSchemaAdaptor'; +import { ParsedJsonSchema } from '../../../api/models/interfaceJsonParserOutput'; +import { TelemetryContent } from '../../../api/models/modelDefinition'; +import { getLocalizedData } from '../../../api/dataTransforms/modelDefinitionTransform'; import '../../../css/_deviceEvents.scss'; const JSON_SPACES = 2; -const LOADING_LOCK = 50; - -export interface ConfigurationSettings { - consumerGroup: string; - useBuiltInEventHub: boolean; - customEventHubName?: string; - customEventHubConnectionString?: string; -} +const LOADING_LOCK = 2000; export const DeviceEvents: React.FC = () => { - let timerID: any; // tslint:disable-line:no-any const { t } = useTranslation(); - const { search } = useLocation(); + const { search, pathname } = useLocation(); + const history = useHistory(); const deviceId = getDeviceIdFromQueryString(search); + const [ localState, dispatch ] = useAsyncSagaReducer(deviceEventsReducer, EventMonitoringSaga, deviceEventsStateInitial(), 'deviceEventsState'); + const synchronizationStatus = localState.synchronizationStatus; + const events = localState.payload; + + // event hub settings const [ consumerGroup, setConsumerGroup] = React.useState(DEFAULT_CONSUMER_GROUP); const [ customEventHubConnectionString, setCustomEventHubConnectionString] = React.useState(undefined); const [ customEventHubName, setCustomEventHubName] = React.useState(undefined); - const [ events, SetEvents] = React.useState([]); const [ startTime, SetStartTime] = React.useState(new Date(new Date().getTime() - MILLISECONDS_IN_MINUTE)); const [ useBuiltInEventHub, setUseBuiltInEventHub] = React.useState(true); - const [ hasMore, setHasMore ] = React.useState(false); - const [ loading, setLoading ] = React.useState(false); + const [ showSystemProperties, setShowSystemProperties ] = React.useState(false); + + // event message state const [ loadingAnnounced, setLoadingAnnounced ] = React.useState(undefined); const [ monitoringData, setMonitoringData ] = React.useState(false); - const [ synchronizationStatus, setSynchronizationStatus ] = React.useState(SynchronizationStatus.initialized); - const [ showSystemProperties, setShowSystemProperties ] = React.useState(false); + + // pnp events specific + const TELEMETRY_SCHEMA_PROP = MESSAGE_PROPERTIES.IOTHUB_MESSAGE_SCHEMA; + const componentName = getComponentNameFromQueryString(search); // if component name exist, we are in pnp context + const interfaceId = getInterfaceIdFromQueryString(search); + const { pnpState, getModelDefinition } = usePnpStateContext(); + const modelDefinitionWithSource = pnpState.modelDefinitionWithSource.payload; + const modelDefinition = modelDefinitionWithSource && modelDefinitionWithSource.modelDefinition; + const isLoading = pnpState.modelDefinitionWithSource.synchronizationStatus === SynchronizationStatus.working; + const telemetrySchema = React.useMemo(() => getDeviceTelemetry(modelDefinition), [modelDefinition]); + const [ showPnpModeledEvents, setShowPnpModeledEvents ] = React.useState(false); + React.useEffect(() => { return () => { stopMonitoring(); }; }, []); + // tslint:disable-next-line: cyclomatic-complexity + React.useEffect(() => { + if (synchronizationStatus === SynchronizationStatus.fetched) { + if (appConfig.hostMode === HostMode.Electron) { + if (monitoringData) { + SetStartTime(new Date()); + setTimeout(() => { + fetchData(); + }, LOADING_LOCK); + } + else { + stopMonitoring(); + } + } + else { + stopMonitoring(); + } + } + if (synchronizationStatus === SynchronizationStatus.upserted) { + SetStartTime(new Date()); + setMonitoringData(false); + } + if (monitoringData && synchronizationStatus === SynchronizationStatus.failed) { + stopMonitoring(); + } + }, [synchronizationStatus]); + const createCommandBarItems = (): ICommandBarItemProps[] => { - return [ - createStartMonitoringCommandItem(), - createClearCommandItem(), - createSystemPropertiesCommandItem() - ]; + if (componentName) { + return [createStartMonitoringCommandItem(), + createPnpModeledEventsCommandItem(), + createSystemPropertiesCommandItem(), + createRefreshCommandItem(), + createClearCommandItem() + ]; + } + else { + return [createStartMonitoringCommandItem(), + createSystemPropertiesCommandItem(), + createClearCommandItem() + ]; + } }; const createClearCommandItem = (): ICommandBarItemProps => { return { ariaLabel: t(ResourceKeys.deviceEvents.command.clearEvents), - disabled: events.length === 0 || synchronizationStatus === SynchronizationStatus.updating, iconProps: { - iconName: CLEAR + iconName: REMOVE }, key: CLEAR, name: t(ResourceKeys.deviceEvents.command.clearEvents), @@ -86,7 +143,7 @@ export const DeviceEvents: React.FC = () => { const createSystemPropertiesCommandItem = (): ICommandBarItemProps => { return { ariaLabel: t(ResourceKeys.deviceEvents.command.showSystemProperties), - disabled: synchronizationStatus === SynchronizationStatus.updating, + disabled: synchronizationStatus === SynchronizationStatus.updating || showPnpModeledEvents, iconProps: { iconName: showSystemProperties ? CHECKED_CHECKBOX : EMPTY_CHECKBOX }, @@ -96,6 +153,18 @@ export const DeviceEvents: React.FC = () => { }; }; + const createPnpModeledEventsCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: 'Show modeled events', + iconProps: { + iconName: showPnpModeledEvents ? CHECKED_CHECKBOX : EMPTY_CHECKBOX + }, + key: EMPTY_CHECKBOX, + name: 'Show modeled events', + onClick: onShowPnpModeledEvents + }; + }; + // tslint:disable-next-line: cyclomatic-complexity const createStartMonitoringCommandItem = (): ICommandBarItemProps => { if (appConfig.hostMode === HostMode.Electron) { @@ -215,55 +284,48 @@ export const DeviceEvents: React.FC = () => { ); }; - const stopMonitoring = async () => { - clearTimeout(timerID); - return stopMonitoringEvents(); + const stopMonitoring = () => { + dispatch(stopEventsMonitoringAction.started()); }; const onToggleStart = () => { if (monitoringData) { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - setHasMore(false); - setSynchronizationStatus(SynchronizationStatus.updating); - } else { - setHasMore(true); - setLoading(false); + setMonitoringData(false); setLoadingAnnounced(undefined); + } else { + fetchData(); setMonitoringData(true); + setLoadingAnnounced(); } }; - const renderInfiniteScroll = () => { - const InfiniteScroll = require('react-infinite-scroller'); // https://github.com/CassetteRocks/react-infinite-scroller/issues/110 - return ( - - {renderEvents()} - - ); + const filterMessage = (message: Message) => { + if (!message || !message.systemProperties) { + return false; + } + if (componentName === DEFAULT_COMPONENT_FOR_DIGITAL_TWIN) { + // for default component, we only expect ${IOTHUB_INTERFACE_ID} to be in the system property not ${IOTHUB_COMPONENT_NAME} + return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_INTERFACE_ID] === interfaceId && + !message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME]; + } + return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME] === componentName; }; - const renderEvents = () => { + const renderRawEvents = () => { + const filteredEvents = componentName ? events.filter(result => filterMessage(result)) : events; return ( -
+
{ - events && events.map((event: Message, index) => { + filteredEvents && filteredEvents.map((event: Message, index) => { + const modifiedEvents = showSystemProperties ? event : { + body: event.body, + enqueuedTime: event.enqueuedTime, + properties: event.properties + }; return (
- {
{parseDateTimeString(event.enqueuedTime)}:
} -
{JSON.stringify(event, undefined, JSON_SPACES)}
+ {
{parseDateTimeString(modifiedEvents.enqueuedTime)}:
} +
{JSON.stringify(modifiedEvents, undefined, JSON_SPACES)}
); }) @@ -272,87 +334,314 @@ export const DeviceEvents: React.FC = () => { ); }; - const renderLoader = (): JSX.Element => { + //#region pnp specific render + const renderPnpModeledEvents = () => { return ( -
- -

{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}

+
+ { + events && events.length > 0 && + <> +
+
+ {t(ResourceKeys.deviceEvents.columns.timestamp)} + {t(ResourceKeys.deviceEvents.columns.displayName)} + {t(ResourceKeys.deviceEvents.columns.schema)} + {t(ResourceKeys.deviceEvents.columns.unit)} + {t(ResourceKeys.deviceEvents.columns.value)} +
+
+
+ { + events.map((event: Message, index) => { + return !event.systemProperties ? renderEventsWithNoSystemProperties(event, index) : + event.systemProperties[TELEMETRY_SCHEMA_PROP] ? + renderEventsWithSchemaProperty(event, index) : + renderEventsWithNoSchemaProperty(event, index); + }) + } +
+ + }
); }; - const fetchData = () => { - if (!loading && monitoringData) { - setLoading(true); - setLoadingAnnounced(); - timerID = setTimeout( - () => { - let parameters: MonitorEventsParameters = { - consumerGroup, - deviceId, - fetchSystemProperties: showSystemProperties, - startTime - }; + const renderEventsWithNoSystemProperties = (event: Message, index: number, ) => { + return ( +
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName()} + {renderEventSchema()} + {renderEventUnit()} + {renderMessageBodyWithNoSchema(event.body)} + +
+
+ ); + }; + + const renderEventsWithSchemaProperty = (event: Message, index: number) => { + const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(event.systemProperties[TELEMETRY_SCHEMA_PROP]); + + return ( +
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName(telemetryModelDefinition)} + {renderEventSchema(telemetryModelDefinition)} + {renderEventUnit(telemetryModelDefinition)} + {renderMessageBodyWithSchema(event.body, parsedSchema, event.systemProperties[TELEMETRY_SCHEMA_PROP])} + +
+
+ ); + }; + + const renderEventsWithNoSchemaProperty = (event: Message, index: number) => { + const telemetryKeys = Object.keys(event.body); + if (telemetryKeys && telemetryKeys.length !== 0) { + return telemetryKeys.map((key, keyIndex) => { + const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(key); + const partialEventBody: any = {}; // tslint:disable-line:no-any + partialEventBody[key] = event.body[key]; + const isNotItemLast = keyIndex !== telemetryKeys.length - 1; + + return ( +
+
+ + {renderTimestamp(keyIndex === 0 ? event.enqueuedTime : null)} + {renderEventName(telemetryModelDefinition)} + {renderEventSchema(telemetryModelDefinition)} + {renderEventUnit(telemetryModelDefinition)} + {renderMessageBodyWithSchema(partialEventBody, parsedSchema, key)} + +
+
+ ); + }); + } + return ( +
+
+ + {renderTimestamp(event.enqueuedTime)} + {renderEventName()} + {renderEventSchema()} + {renderEventUnit()} + {renderMessageBodyWithSchema(event.body, null, null)} + +
+
+ ); + }; + + const renderTimestamp = (enqueuedTime: string) => { + return( +
+ +
+ ); + }; + + const renderEventName = (telemetryModelDefinition?: TelemetryContent) => { + const displayName = telemetryModelDefinition ? getLocalizedData(telemetryModelDefinition.displayName) : ''; + return( +
+ +
+ ); + }; + + const renderEventSchema = (telemetryModelDefinition?: TelemetryContent) => { + return( +
+ +
+ ); + }; + + const renderEventUnit = (telemetryModelDefinition?: TelemetryContent) => { + return( +
+ +
+ ); + }; + + // tslint:disable-next-line: cyclomatic-complexity + const renderMessageBodyWithSchema = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any + if (key && !schema) { // DTDL doesn't contain corresponding key + const labelContent = t(ResourceKeys.deviceEvents.columns.validation.key.isNotSpecified, { key }); + return( +
+ + {JSON.stringify(eventBody, undefined, JSON_SPACES)} + + +
+ ); + } - if (!useBuiltInEventHub && customEventHubConnectionString && customEventHubName) { - parameters = { - ...parameters, - customEventHubConnectionString, - customEventHubName - }; + if (eventBody && Object.keys(eventBody) && Object.keys(eventBody)[0] !== key) { // key in event body doesn't match property name + const labelContent = Object.keys(eventBody)[0] ? t(ResourceKeys.deviceEvents.columns.validation.key.doesNotMatch, { + expectedKey: key, + receivedKey: Object.keys(eventBody)[0] + }) : t(ResourceKeys.deviceEvents.columns.validation.value.bodyIsEmpty); + return( +
+ + {JSON.stringify(eventBody, undefined, JSON_SPACES)} + + +
+ ); + } + + return renderMessageBodyWithValueValidation(eventBody, schema, key); + }; + + const renderMessageBodyWithValueValidation = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any + const errors = getSchemaValidationErrors(eventBody[key], schema, true); + + return( +
+ +
+ ); + }; - monitorEvents(parameters) - .then(results => { - const messages = results ? results.reverse().map((message: Message) => message) : []; - SetEvents([...messages, ...events]); - SetStartTime(new Date()); - setLoading(false); - stopMonitoringIfNecessary(); - }) - .catch (error => { - raiseNotificationToast({ - text: { - translationKey: ResourceKeys.deviceEvents.error, - translationOptions: { - error - } - }, - type: NotificationType.error - }); - stopMonitoringIfNecessary(); - }); - }, - LOADING_LOCK); + const renderMessageBodyWithNoSchema = (eventBody: any) => { // tslint:disable-line:no-any + return( +
+ +
+ ); + }; + + const getModelDefinitionAndSchema = (key: string): TelemetrySchema => { + const matchingSchema = telemetrySchema.filter(schema => schema.telemetryModelDefinition.name === key); + const telemetryModelDefinition = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].telemetryModelDefinition; + const parsedSchema = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].parsedSchema; + return { + parsedSchema, + telemetryModelDefinition + }; + }; + + const createRefreshCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: t(ResourceKeys.deviceEvents.command.refresh), + disabled: synchronizationStatus === SynchronizationStatus.updating, + iconProps: {iconName: REFRESH}, + key: REFRESH, + name: t(ResourceKeys.deviceEvents.command.refresh), + onClick: getModelDefinition + }; + }; + + const createNavigateBackCommandItem = (): ICommandBarItemProps => { + return { + ariaLabel: t(ResourceKeys.deviceEvents.command.close), + iconProps: {iconName: NAVIGATE_BACK}, + key: NAVIGATE_BACK, + name: t(ResourceKeys.deviceEvents.command.close), + onClick: handleClose + }; + }; + + const handleClose = () => { + const path = pathname.replace(/\/ioTPlugAndPlayDetail\/events\/.*/, ``); + history.push(`${path}/?${ROUTE_PARAMS.DEVICE_ID}=${encodeURIComponent(deviceId)}`); + }; + //#endregion + + const renderLoader = (): JSX.Element => { + return ( + <> + {monitoringData && ( +
+ +

{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}

+
+ )} + + ); + }; + + const fetchData = () => { + let parameters: MonitorEventsParameters = { + consumerGroup, + deviceId, + startTime + }; + + if (!useBuiltInEventHub) { + parameters = { + ...parameters, + customEventHubConnectionString, + customEventHubName + }; } + + dispatch(startEventsMonitoringAction.started(parameters)); }; const onClearData = () => { - SetEvents([]); + dispatch(clearMonitoringEventsAction()); }; const onShowSystemProperties = () => { setShowSystemProperties(!showSystemProperties); }; - const stopMonitoringIfNecessary = () => { - if (appConfig.hostMode === HostMode.Electron) { - return; - } - else { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - } + const onShowPnpModeledEvents = () => { + setShowPnpModeledEvents(!showPnpModeledEvents); }; + if (isLoading) { + return ; + } + return (
{ /> {renderConsumerGroup()} {renderCustomEventHub()} - {renderInfiniteScroll()} +
+ {renderLoader()} + {showPnpModeledEvents ? renderPnpModeledEvents() : renderRawEvents()} +
{loadingAnnounced}
); diff --git a/src/app/devices/deviceEvents/reducer.spec.ts b/src/app/devices/deviceEvents/reducer.spec.ts new file mode 100644 index 000000000..eab91ba17 --- /dev/null +++ b/src/app/devices/deviceEvents/reducer.spec.ts @@ -0,0 +1,64 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import 'jest'; +import { START_EVENTS_MONITORING, STOP_EVENTS_MONITORING } from '../../constants/actionTypes'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import { deviceEventsReducer } from './reducers'; +import { deviceEventsStateInitial } from './state'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; +import { DEFAULT_CONSUMER_GROUP } from './../../constants/apiConstants'; + +describe('deviceEventsReducer', () => { + const deviceId = 'testDeviceId'; + const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, startTime: new Date()}; + const events = [{ + body: { + humid: '123' // intentionally set a value which type is double + }, + enqueuedTime: '2019-10-14T21:44:58.397Z', + systemProperties: { + 'iothub-message-schema': 'humid' + } + }]; + it (`handles ${START_EVENTS_MONITORING}/ACTION_START action`, () => { + const action = startEventsMonitoringAction.started(params); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.working); + }); + + it (`handles ${START_EVENTS_MONITORING}/ACTION_DONE action`, () => { + const action = startEventsMonitoringAction.done({params, result: events}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual(events); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.fetched); + }); + + it (`handles ${START_EVENTS_MONITORING}/ACTION_FAILED action`, () => { + const action = startEventsMonitoringAction.failed({error: -1, params}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.failed); + }); + + let initialState = deviceEventsStateInitial(); + initialState = initialState.merge({ + payload: events, + synchronizationStatus: SynchronizationStatus.fetched + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_START action`, () => { + const action = stopEventsMonitoringAction.started(); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.updating); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_DONE action`, () => { + const action = stopEventsMonitoringAction.done({}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.upserted); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); + + it (`handles ${STOP_EVENTS_MONITORING}/ACTION_FAILED action`, () => { + const action = stopEventsMonitoringAction.failed({error: -1}); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).synchronizationStatus).toEqual(SynchronizationStatus.failed); + expect(deviceEventsReducer(deviceEventsStateInitial(), action).payload).toEqual([]); + }); +}); diff --git a/src/app/devices/deviceEvents/reducers.ts b/src/app/devices/deviceEvents/reducers.ts new file mode 100644 index 000000000..03d0cd191 --- /dev/null +++ b/src/app/devices/deviceEvents/reducers.ts @@ -0,0 +1,58 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { DeviceEventsStateInterface, deviceEventsStateInitial, DeviceEventsStateType } from './state'; +import { + startEventsMonitoringAction, + stopEventsMonitoringAction, + clearMonitoringEventsAction +} from './actions'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; +import { MonitorEventsParameters } from '../../api/parameters/deviceParameters'; +import { Message } from '../../api/models/messages'; + +export const deviceEventsReducer = reducerWithInitialState(deviceEventsStateInitial()) + .case(startEventsMonitoringAction.started, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.working + }); + }) + .case(startEventsMonitoringAction.done, (state: DeviceEventsStateType, payload: {params: MonitorEventsParameters, result: Message[]}) => { + const messages = payload.result ? payload.result.reverse().map((message: Message) => message) : []; + let filteredMessages = messages; + if (state.payload.length > 0 && messages.length > 0) { + // filter overlaped messages returned from event hub + filteredMessages = messages.filter(message => message.enqueuedTime > state.payload[0].enqueuedTime); + } + return state.merge({ + payload: [...filteredMessages, ...state.payload], + synchronizationStatus: SynchronizationStatus.fetched + }); + }) + .case(startEventsMonitoringAction.failed, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.failed + }); + }) + .case(stopEventsMonitoringAction.started, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.updating + }); + }) + .case(stopEventsMonitoringAction.done, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.upserted + }); + }) + .case(stopEventsMonitoringAction.failed, (state: DeviceEventsStateType) => { + return state.merge({ + synchronizationStatus: SynchronizationStatus.failed + }); + }) + .case(clearMonitoringEventsAction, (state: DeviceEventsStateType) => { + return state.merge({ + payload: [] + }); + }); diff --git a/src/app/devices/deviceEvents/saga.spec.ts b/src/app/devices/deviceEvents/saga.spec.ts new file mode 100644 index 000000000..02e8a7fcd --- /dev/null +++ b/src/app/devices/deviceEvents/saga.spec.ts @@ -0,0 +1,130 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import 'jest'; +import { put, call } from 'redux-saga/effects'; +// tslint:disable-next-line: no-implicit-dependencies +import { cloneableGenerator } from '@redux-saga/testing-utils'; +import { startEventsMonitoringSagaWorker, stopEventsMonitoringSagaWorker } from './saga'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import * as DevicesService from '../../api/services/devicesService'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { NotificationType } from '../../api/models/notification'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; +import { DEFAULT_CONSUMER_GROUP } from '../../constants/apiConstants'; + +describe('deviceMonitoringSaga', () => { + let startEventsMonitoringSagaGenerator; + let stopEventsMonitoringSagaGenerator; + + const mockMonitorEventsFn = jest.spyOn(DevicesService, 'monitorEvents').mockImplementationOnce(parameters => { + return null; + }); + const mockStopMonitorEventsFn = jest.spyOn(DevicesService, 'stopMonitoringEvents').mockImplementationOnce(() => { + return null; + }); + const deviceId = 'test_id'; + const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, startTime: new Date()}; + + beforeEach(() => { + startEventsMonitoringSagaGenerator = cloneableGenerator(startEventsMonitoringSagaWorker)(startEventsMonitoringAction.started(params)); + stopEventsMonitoringSagaGenerator = cloneableGenerator(stopEventsMonitoringSagaWorker)(); + }); + + it('start device events monitoring', () => { + // call for device id + expect(startEventsMonitoringSagaGenerator.next()).toEqual({ + done: false, + value: call(mockMonitorEventsFn, params) + }); + + // add to store + expect(startEventsMonitoringSagaGenerator.next([])).toEqual({ + done: false, + value: put(startEventsMonitoringAction.done({ + params, + result: [] + })) + }); + + // success + const success = startEventsMonitoringSagaGenerator.clone(); + expect(success.next()).toEqual({ + done: true + }); + + // failure + const failed = startEventsMonitoringSagaGenerator.clone(); + const error = { code: -1 }; + + expect(failed.throw(error)).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.startEventMonitoringOnError, + translationOptions: { + error + }, + }, + type: NotificationType.error + }) + }); + + expect(failed.next([])).toEqual({ + done: false, + value: put(startEventsMonitoringAction.failed({ + error, + params + })) + }); + + expect(failed.next().done).toEqual(true); + }); + + it('stop device events monitoring', () => { + // call for device id + expect(stopEventsMonitoringSagaGenerator.next()).toEqual({ + done: false, + value: call(mockStopMonitorEventsFn) + }); + + // add to store + expect(stopEventsMonitoringSagaGenerator.next([])).toEqual({ + done: false, + value: put(stopEventsMonitoringAction.done({})) + }); + + // success + const success = stopEventsMonitoringSagaGenerator.clone(); + expect(success.next()).toEqual({ + done: true + }); + + // failure + const failed = stopEventsMonitoringSagaGenerator.clone(); + const error = { code: -1 }; + + expect(failed.throw(error)).toEqual({ + done: false, + value: call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.stopEventMonitoringOnError, + translationOptions: { + error + }, + }, + type: NotificationType.error + }) + }); + + expect(failed.next([])).toEqual({ + done: false, + value: put(stopEventsMonitoringAction.failed({ + error + })) + }); + + expect(failed.next().done).toEqual(true); + }); +}); diff --git a/src/app/devices/deviceEvents/saga.ts b/src/app/devices/deviceEvents/saga.ts new file mode 100644 index 000000000..b8b685d51 --- /dev/null +++ b/src/app/devices/deviceEvents/saga.ts @@ -0,0 +1,55 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { call, put, all, takeLatest, takeEvery } from 'redux-saga/effects'; +import { Action } from 'typescript-fsa'; +import { monitorEvents, stopMonitoringEvents } from '../../api/services/devicesService'; +import { NotificationType } from '../../api/models/notification'; +import { ResourceKeys } from '../../../localization/resourceKeys'; +import { startEventsMonitoringAction, stopEventsMonitoringAction } from './actions'; +import { raiseNotificationToast } from '../../notifications/components/notificationToast'; +import { MonitorEventsParameters } from '../../api/parameters/deviceParameters'; + +export function* startEventsMonitoringSagaWorker(action: Action) { + try { + const messages = yield call(monitorEvents, action.payload); + yield put(startEventsMonitoringAction.done({params: action.payload, result: messages})); + } catch (error) { + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.startEventMonitoringOnError, + translationOptions: { + error, + }, + }, + type: NotificationType.error + }); + yield put(startEventsMonitoringAction.failed({params: action.payload, error})); + } +} + +export function* stopEventsMonitoringSagaWorker() { + try { + yield call(stopMonitoringEvents); + yield put(stopEventsMonitoringAction.done({})); + } catch (error) { + yield call(raiseNotificationToast, { + text: { + translationKey: ResourceKeys.notifications.stopEventMonitoringOnError, + translationOptions: { + error, + }, + }, + type: NotificationType.error + }); + yield put(stopEventsMonitoringAction.failed({error})); + } +} + +export function* EventMonitoringSaga() { + yield all([ + takeEvery(startEventsMonitoringAction.started.type, startEventsMonitoringSagaWorker), + takeLatest(stopEventsMonitoringAction.started.type, stopEventsMonitoringSagaWorker), + ]); +} diff --git a/src/app/devices/deviceEvents/state.ts b/src/app/devices/deviceEvents/state.ts new file mode 100644 index 000000000..d6ec024e9 --- /dev/null +++ b/src/app/devices/deviceEvents/state.ts @@ -0,0 +1,18 @@ +/*********************************************************** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License + **********************************************************/ +import { Record } from 'immutable'; +import { IM } from '../../shared/types/types'; +import { SynchronizationWrapper } from '../../api/models/synchronizationWrapper'; +import { Message } from '../../api/models/messages'; +import { SynchronizationStatus } from '../../api/models/synchronizationStatus'; + +export interface DeviceEventsStateInterface extends SynchronizationWrapper{} + +export const deviceEventsStateInitial = Record({ + payload: [], + synchronizationStatus: SynchronizationStatus.initialized +}); + +export type DeviceEventsStateType = IM; diff --git a/src/app/devices/pnp/components/deviceEvents/__snapshots__/deviceEventsPerInterface.spec.tsx.snap b/src/app/devices/pnp/components/deviceEvents/__snapshots__/deviceEventsPerInterface.spec.tsx.snap deleted file mode 100644 index e98bdaf2b..000000000 --- a/src/app/devices/pnp/components/deviceEvents/__snapshots__/deviceEventsPerInterface.spec.tsx.snap +++ /dev/null @@ -1,288 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/devices/deviceEventsPerInterface matches snapshot while interface cannot be found 1`] = ` -
- - - deviceEvents.noEvent - -
-`; - -exports[`components/devices/deviceEventsPerInterface matches snapshot while interface definition is retrieved in electron 1`] = ` -
- - - - -
- -

- deviceEvents.infiniteScroll.loading -

-
-
- } - pageStart={0} - ref={null} - threshold={250} - useCapture={false} - useWindow={true} - > -
-
-
- -
-`; - -exports[`components/devices/deviceEventsPerInterface matches snapshot while interface definition is retrieved in hosted environment 1`] = ` -
- - - - -
- -

- deviceEvents.infiniteScroll.loading -

-
-
- } - pageStart={0} - ref={null} - threshold={250} - useCapture={false} - useWindow={true} - > -
-
-
- -
-`; diff --git a/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.spec.tsx b/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.spec.tsx deleted file mode 100644 index e3f534d00..000000000 --- a/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.spec.tsx +++ /dev/null @@ -1,217 +0,0 @@ -/*********************************************************** - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License - **********************************************************/ -import 'jest'; -import * as React from 'react'; -import { act } from 'react-dom/test-utils'; -import { shallow, mount } from 'enzyme'; -import { Shimmer } from 'office-ui-fabric-react/lib/components/Shimmer'; -import { CommandBar } from 'office-ui-fabric-react/lib/components/CommandBar'; -import { TextField } from 'office-ui-fabric-react/lib/components/TextField'; -import { DeviceEventsPerInterface } from './deviceEventsPerInterface'; -import { ErrorBoundary } from '../../../shared/components/errorBoundary'; -import { appConfig, HostMode } from '../../../../../appConfig/appConfig'; -import { ResourceKeys } from '../../../../../localization/resourceKeys'; -import { SynchronizationStatus } from '../../../../api/models/synchronizationStatus'; -import { DEFAULT_CONSUMER_GROUP } from '../../../../constants/apiConstants'; -import { PnpStateInterface, pnpStateInitial } from '../../state'; -import * as PnpContext from '../../../../shared/contexts/pnpStateContext'; -import { testModelDefinition } from './testData'; -import { REPOSITORY_LOCATION_TYPE } from '../../../../constants/repositoryLocationTypes'; - -const pathname = `#/devices/detail/events/?id=device1`; -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ push: jest.fn() }), - useLocation: () => ({ search: '?id=device1', pathname }), -})); - -describe('components/devices/deviceEventsPerInterface', () => { - const getModelDefinitionMock = jest.fn(); - - const mockFetchedState = () => { - const pnpState: PnpStateInterface = { - ...pnpStateInitial(), - modelDefinitionWithSource: { - payload: { - isModelValid: true, - modelDefinition: testModelDefinition, - source: REPOSITORY_LOCATION_TYPE.Public, - }, - synchronizationStatus: SynchronizationStatus.fetched - } - }; - jest.spyOn(PnpContext, 'usePnpStateContext').mockReturnValue({pnpState, dispatch: jest.fn(), getModelDefinition: getModelDefinitionMock}); - }; - - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('renders Shimmer while loading', () => { - const pnpState: PnpStateInterface = { - ...pnpStateInitial(), - modelDefinitionWithSource: { - synchronizationStatus: SynchronizationStatus.working - } - }; - jest.spyOn(PnpContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); - const wrapper = mount(); - expect(wrapper.find(Shimmer)).toBeDefined(); - }); - - it('matches snapshot while interface cannot be found', () => { - const pnpState: PnpStateInterface = { - ...pnpStateInitial(), - modelDefinitionWithSource: { - payload: null, - synchronizationStatus: SynchronizationStatus.fetched - } - }; - jest.spyOn(PnpContext, 'usePnpStateContext').mockReturnValueOnce({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()}); - expect(shallow()).toMatchSnapshot(); - }); - - it('matches snapshot while interface definition is retrieved in electron', () => { - appConfig.hostMode = HostMode.Electron; - mockFetchedState(); - expect(shallow()).toMatchSnapshot(); - }); - - it('matches snapshot while interface definition is retrieved in hosted environment', () => { - appConfig.hostMode = HostMode.Browser; - mockFetchedState(); - expect(shallow()).toMatchSnapshot(); - }); - - it('renders events which body\'s value type is wrong with expected columns', () => { - const events = [{ - body: { - humid: '123' // intentionally set a value which type is double - }, - enqueuedTime: '2019-10-14T21:44:58.397Z', - systemProperties: { - 'iothub-message-schema': 'humid' - } - }]; - - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - mockFetchedState(); - const wrapper = mount(); - const errorBoundary = wrapper.find(ErrorBoundary); - expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); - expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[1].props['aria-label']).toEqual(ResourceKeys.deviceEvents.columns.validation.value.label); // tslint:disable-line:no-magic-numbers - }); - - it('renders events which body\'s key name is wrong with expected columns', () => { - const events = [{ - body: { - 'non-matching-key': 0 - }, - enqueuedTime: '2019-10-14T21:44:58.397Z', - systemProperties: { - 'iothub-message-schema': 'humid' - } - }]; - - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - mockFetchedState(); - const wrapper = mount(); - - const errorBoundary = wrapper.find(ErrorBoundary); - expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); - expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers - }); - - it('renders events when body is exploded and schema is not provided in system properties', () => { - const events = [{ - body: { - 'humid': 0, - 'humid-foo': 'test' - }, - enqueuedTime: '2019-10-14T21:44:58.397Z', - systemProperties: {} - }]; - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - mockFetchedState(); - - const wrapper = shallow(); - let errorBoundary = wrapper.find(ErrorBoundary).first(); - expect(errorBoundary.children().at(1).props().children.props.children).toEqual('humid (Temperature)'); - expect(errorBoundary.children().at(2).props().children.props.children).toEqual('double'); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({humid: 0}, undefined, 2)); // tslint:disable-line:no-magic-numbers - - errorBoundary = wrapper.find(ErrorBoundary).at(1); - expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); - expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[0]).toEqual(JSON.stringify({'humid-foo': 'test'}, undefined, 2)); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children[1].props.className).toEqual('value-validation-error'); // tslint:disable-line:no-magic-numbers - }); - - it('renders events which body\'s key name is not populated', () => { - const events = [{ - body: { - 'non-matching-key': 0 - }, - enqueuedTime: '2019-10-14T21:44:58.397Z' - }]; - - const realUseState = React.useState; - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP)); - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(events)); - mockFetchedState(); - - const wrapper = shallow(); - const errorBoundary = wrapper.find(ErrorBoundary); - expect(errorBoundary.children().at(1).props().children.props.children).toEqual('--'); - expect(errorBoundary.children().at(2).props().children.props.children).toEqual('--'); // tslint:disable-line:no-magic-numbers - expect(errorBoundary.children().at(4).props().children.props.children).toEqual(JSON.stringify(events[0].body, undefined, 2)); // tslint:disable-line:no-magic-numbers - }); - - it('changes state accordingly when command bar buttons are clicked', () => { - mockFetchedState(); - const wrapper = shallow(); - const commandBar = wrapper.find(CommandBar).first(); - // click the start button - act(() => commandBar.props().items[0].onClick()); - wrapper.update(); - expect(wrapper.find('.device-events-container').first().props().hasMore).toBeTruthy(); - - // click the start button again which has been toggled to stop button - act(() => wrapper.find(CommandBar).first().props().items[0].onClick()); - wrapper.update(); - expect(wrapper.find('.device-events-container').first().props().hasMore).toBeFalsy(); - - // click the refresh button - act(() => commandBar.props().items[1].onClick()); - wrapper.update(); - expect(getModelDefinitionMock).toBeCalled(); - - // click the clear events button - act(() => commandBar.props().items[2].onClick()); // tslint:disable-line:no-magic-numbers - wrapper.update(); - // tslint:disable-next-line: no-magic-numbers - expect(wrapper.find(CommandBar).first().props().items[2].disabled).toBeTruthy(); - }); - - it('changes state accordingly when consumer group value is changed', () => { - mockFetchedState(); - const wrapper = shallow(); - const textField = wrapper.find(TextField).first(); - act(() => textField.props().onChange({ target: null}, 'testGroup')); - wrapper.update(); - - expect(wrapper.find(TextField).first().props().value).toEqual('testGroup'); - }); -}); diff --git a/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.tsx b/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.tsx deleted file mode 100644 index 6b3181aed..000000000 --- a/src/app/devices/pnp/components/deviceEvents/deviceEventsPerInterface.tsx +++ /dev/null @@ -1,581 +0,0 @@ -/*********************************************************** - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License - **********************************************************/ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/components/CommandBar'; -import { Label } from 'office-ui-fabric-react/lib/components/Label'; -import { Spinner } from 'office-ui-fabric-react/lib/components/Spinner'; -import { TextField, ITextFieldProps } from 'office-ui-fabric-react/lib/components/TextField'; -import { Announced } from 'office-ui-fabric-react/lib/components/Announced'; -import { Toggle } from 'office-ui-fabric-react/lib/components/Toggle'; -import { useLocation, useHistory } from 'react-router-dom'; -import { ResourceKeys } from '../../../../../localization/resourceKeys'; -import { monitorEvents, stopMonitoringEvents } from '../../../../api/services/devicesService'; -import { Message, MESSAGE_SYSTEM_PROPERTIES, MESSAGE_PROPERTIES } from '../../../../api/models/messages'; -import { parseDateTimeString } from '../../../../api/dataTransforms/transformHelper'; -import { REFRESH, STOP, START, REMOVE, NAVIGATE_BACK } from '../../../../constants/iconNames'; -import { ParsedJsonSchema } from '../../../../api/models/interfaceJsonParserOutput'; -import { TelemetryContent } from '../../../../api/models/modelDefinition'; -import { getInterfaceIdFromQueryString, getDeviceIdFromQueryString, getComponentNameFromQueryString } from '../../../../shared/utils/queryStringHelper'; -import { SynchronizationStatus } from '../../../../api/models/synchronizationStatus'; -import { DEFAULT_CONSUMER_GROUP } from '../../../../constants/apiConstants'; -import { ErrorBoundary } from '../../../shared/components/errorBoundary'; -import { getLocalizedData } from '../../../../api/dataTransforms/modelDefinitionTransform'; -import { NotificationType } from '../../../../api/models/notification'; -import { MultiLineShimmer } from '../../../../shared/components/multiLineShimmer'; -import { LabelWithTooltip } from '../../../../shared/components/labelWithTooltip'; -import { MILLISECONDS_IN_MINUTE } from '../../../../constants/shared'; -import { appConfig, HostMode } from '../../../../../appConfig/appConfig'; -import { SemanticUnit } from '../../../../shared/units/components/semanticUnit'; -import { ROUTE_PARAMS } from '../../../../constants/routes'; -import { raiseNotificationToast } from '../../../../notifications/components/notificationToast'; -import { usePnpStateContext } from '../../../../shared/contexts/pnpStateContext'; -import { getDeviceTelemetry, TelemetrySchema } from './dataHelper'; -import { DEFAULT_COMPONENT_FOR_DIGITAL_TWIN } from '../../../../constants/devices'; -import { getSchemaValidationErrors } from '../../../../shared/utils/jsonSchemaAdaptor'; -import '../../../../css/_deviceEvents.scss'; - -const JSON_SPACES = 2; -const LOADING_LOCK = 50; -const TELEMETRY_SCHEMA_PROP = MESSAGE_PROPERTIES.IOTHUB_MESSAGE_SCHEMA; - -export const DeviceEventsPerInterface: React.FC = () => { - let timerID: any; // tslint:disable-line:no-any - - const { t } = useTranslation(); - const { search, pathname } = useLocation(); - const history = useHistory(); - const componentName = getComponentNameFromQueryString(search); - const deviceId = getDeviceIdFromQueryString(search); - const interfaceId = getInterfaceIdFromQueryString(search); - - const { pnpState, getModelDefinition } = usePnpStateContext(); - const modelDefinitionWithSource = pnpState.modelDefinitionWithSource.payload; - const modelDefinition = modelDefinitionWithSource && modelDefinitionWithSource.modelDefinition; - const isLoading = pnpState.modelDefinitionWithSource.synchronizationStatus === SynchronizationStatus.working; - const telemetrySchema = React.useMemo(() => getDeviceTelemetry(modelDefinition), [modelDefinition]); - - const [ consumerGroup, setConsumerGroup] = React.useState(DEFAULT_CONSUMER_GROUP); - const [ events, SetEvents] = React.useState([]); - const [ startTime, SetStartTime] = React.useState(new Date(new Date().getTime() - MILLISECONDS_IN_MINUTE)); - const [ hasMore, setHasMore ] = React.useState(false); - const [ loading, setLoading ] = React.useState(false); - const [ loadingAnnounced, setLoadingAnnounced ] = React.useState(undefined); - const [ monitoringData, setMonitoringData ] = React.useState(false); - const [ synchronizationStatus, setSynchronizationStatus ] = React.useState(SynchronizationStatus.initialized); - const [ showRawEvent, setShowRawEvent ] = React.useState(false); - - React.useEffect(() => { - return () => { - stopMonitoring(); - }; - }, []); - - const renderCommandBar = () => { - return ( - - ); - }; - - const createClearCommandItem = (): ICommandBarItemProps => { - return { - ariaLabel: t(ResourceKeys.deviceEvents.command.clearEvents), - disabled: events.length === 0 || synchronizationStatus === SynchronizationStatus.updating, - iconProps: {iconName: REMOVE}, - key: REMOVE, - name: t(ResourceKeys.deviceEvents.command.clearEvents), - onClick: onClearData - }; - }; - - const createRefreshCommandItem = (): ICommandBarItemProps => { - return { - ariaLabel: t(ResourceKeys.deviceEvents.command.refresh), - disabled: synchronizationStatus === SynchronizationStatus.updating, - iconProps: {iconName: REFRESH}, - key: REFRESH, - name: t(ResourceKeys.deviceEvents.command.refresh), - onClick: getModelDefinition - }; - }; - - const createStartMonitoringCommandItem = (): ICommandBarItemProps => { - if (appConfig.hostMode === HostMode.Electron) { - const label = monitoringData ? t(ResourceKeys.deviceEvents.command.stop) : t(ResourceKeys.deviceEvents.command.start); - const icon = monitoringData ? STOP : START; - return { - ariaLabel: label, - disabled: synchronizationStatus === SynchronizationStatus.updating, - iconProps: { - iconName: icon - }, - key: icon, - name: label, - onClick: onToggleStart - }; - } - else { - return { - ariaLabel: t(ResourceKeys.deviceEvents.command.fetch), - disabled: synchronizationStatus === SynchronizationStatus.updating || monitoringData, - iconProps: { - iconName: START - }, - key: START, - name: t(ResourceKeys.deviceEvents.command.fetch), - onClick: onToggleStart - }; - } - }; - - const createNavigateBackCommandItem = (): ICommandBarItemProps => { - return { - ariaLabel: t(ResourceKeys.deviceEvents.command.close), - iconProps: {iconName: NAVIGATE_BACK}, - key: NAVIGATE_BACK, - name: t(ResourceKeys.deviceEvents.command.close), - onClick: handleClose - }; - }; - - const consumerGroupChange = (event: React.FormEvent, newValue?: string) => { - if (!!newValue) { - setConsumerGroup(newValue); - } - }; - - const renderConsumerGroupLabel = () => (consumerGroupProps: ITextFieldProps) => { - return ( - - {consumerGroupProps.label} - - ); - }; - - const renderRawTelemetryToggle = () => { - return ( - - ); - }; - - const changeToggle = () => { - setShowRawEvent(!showRawEvent); - }; - - const stopMonitoring = async () => { - clearTimeout(timerID); - return stopMonitoringEvents(); - }; - - const onToggleStart = () => { - if (monitoringData) { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - setHasMore(false); - setSynchronizationStatus(SynchronizationStatus.updating); - } else { - setHasMore(true); - setLoading(false); - setLoadingAnnounced(undefined); - setMonitoringData(true); - } - }; - - const renderInfiniteScroll = () => { - const InfiniteScroll = require('react-infinite-scroller'); // https://github.com/CassetteRocks/react-infinite-scroller/issues/110 - return ( - -
- {showRawEvent ? renderRawEvents() : renderEvents()} -
-
- ); - }; - - const renderRawEvents = () => { - return ( -
- { - events && events.map((event: Message, index) => { - return ( -
- {
{parseDateTimeString(event.enqueuedTime)}:
} -
{JSON.stringify(event, undefined, JSON_SPACES)}
-
- ); - }) - } -
- ); - }; - - const renderEvents = () => { - return ( -
- { - events && events.length > 0 && - <> -
-
- {t(ResourceKeys.deviceEvents.columns.timestamp)} - {t(ResourceKeys.deviceEvents.columns.displayName)} - {t(ResourceKeys.deviceEvents.columns.schema)} - {t(ResourceKeys.deviceEvents.columns.unit)} - {t(ResourceKeys.deviceEvents.columns.value)} -
-
-
- { - events.map((event: Message, index) => { - return !event.systemProperties ? renderEventsWithNoSystemProperties(event, index) : - event.systemProperties[TELEMETRY_SCHEMA_PROP] ? - renderEventsWithSchemaProperty(event, index) : - renderEventsWithNoSchemaProperty(event, index); - }) - } -
- - } -
- ); - }; - - const renderEventsWithSchemaProperty = (event: Message, index: number) => { - const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(event.systemProperties[TELEMETRY_SCHEMA_PROP]); - - return ( -
-
- - {renderTimestamp(event.enqueuedTime)} - {renderEventName(telemetryModelDefinition)} - {renderEventSchema(telemetryModelDefinition)} - {renderEventUnit(telemetryModelDefinition)} - {renderMessageBodyWithSchema(event.body, parsedSchema, event.systemProperties[TELEMETRY_SCHEMA_PROP])} - -
-
- ); - }; - - const renderEventsWithNoSchemaProperty = (event: Message, index: number) => { - const telemetryKeys = Object.keys(event.body); - if (telemetryKeys && telemetryKeys.length !== 0) { - return telemetryKeys.map((key, keyIndex) => { - const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(key); - const partialEventBody: any = {}; // tslint:disable-line:no-any - partialEventBody[key] = event.body[key]; - const isNotItemLast = keyIndex !== telemetryKeys.length - 1; - - return ( -
-
- - {renderTimestamp(keyIndex === 0 ? event.enqueuedTime : null)} - {renderEventName(telemetryModelDefinition)} - {renderEventSchema(telemetryModelDefinition)} - {renderEventUnit(telemetryModelDefinition)} - {renderMessageBodyWithSchema(partialEventBody, parsedSchema, key)} - -
-
- ); - }); - } - return ( -
-
- - {renderTimestamp(event.enqueuedTime)} - {renderEventName()} - {renderEventSchema()} - {renderEventUnit()} - {renderMessageBodyWithSchema(event.body, null, null)} - -
-
- ); - }; - - const renderEventsWithNoSystemProperties = (event: Message, index: number, ) => { - return ( -
-
- - {renderTimestamp(event.enqueuedTime)} - {renderEventName()} - {renderEventSchema()} - {renderEventUnit()} - {renderMessageBodyWithNoSchema(event.body)} - -
-
- ); - }; - - const renderTimestamp = (enqueuedTime: string) => { - return( -
- -
- ); - }; - - const renderEventName = (telemetryModelDefinition?: TelemetryContent) => { - const displayName = telemetryModelDefinition ? getLocalizedData(telemetryModelDefinition.displayName) : ''; - return( -
- -
- ); - }; - - const renderEventSchema = (telemetryModelDefinition?: TelemetryContent) => { - return( -
- -
- ); - }; - - const renderEventUnit = (telemetryModelDefinition?: TelemetryContent) => { - return( -
- -
- ); - }; - - // tslint:disable-next-line: cyclomatic-complexity - const renderMessageBodyWithSchema = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any - if (key && !schema) { // DTDL doesn't contain corresponding key - const labelContent = t(ResourceKeys.deviceEvents.columns.validation.key.isNotSpecified, { key }); - return( -
- - {JSON.stringify(eventBody, undefined, JSON_SPACES)} - - -
- ); - } - - if (eventBody && Object.keys(eventBody) && Object.keys(eventBody)[0] !== key) { // key in event body doesn't match property name - const labelContent = Object.keys(eventBody)[0] ? t(ResourceKeys.deviceEvents.columns.validation.key.doesNotMatch, { - expectedKey: key, - receivedKey: Object.keys(eventBody)[0] - }) : t(ResourceKeys.deviceEvents.columns.validation.value.bodyIsEmpty); - return( -
- - {JSON.stringify(eventBody, undefined, JSON_SPACES)} - - -
- ); - } - - return renderMessageBodyWithValueValidation(eventBody, schema, key); - }; - - const renderMessageBodyWithValueValidation = (eventBody: any, schema: ParsedJsonSchema, key: string) => { // tslint:disable-line:no-any - const errors = getSchemaValidationErrors(eventBody[key], schema, true); - - return( -
- -
- ); - }; - - const renderMessageBodyWithNoSchema = (eventBody: any) => { // tslint:disable-line:no-any - return( -
- -
- ); - }; - - const renderLoader = (): JSX.Element => { - return ( -
-
- -

{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}

-
-
- ); - }; - - const filterMessage = (message: Message) => { - if (!message || !message.systemProperties) { - return false; - } - if (componentName === DEFAULT_COMPONENT_FOR_DIGITAL_TWIN) { - // for default component, we only expect ${IOTHUB_INTERFACE_ID} to be in the system property not ${IOTHUB_COMPONENT_NAME} - return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_INTERFACE_ID] === interfaceId && - !message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME]; - } - return message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_COMPONENT_NAME] === componentName; - }; - - const fetchData = () => () => { - if (!loading && monitoringData) { - setLoading(true); - setLoadingAnnounced(); - timerID = setTimeout( - () => { - monitorEvents({ - consumerGroup, - deviceId, - fetchSystemProperties: true, - startTime - }) - .then((results: Message[]) => { - const messages = results && results - .filter(result => filterMessage(result)) - .reverse().map((message: Message) => message); - SetEvents([...messages, ...events]); - SetStartTime(new Date()); - setLoading(false); - stopMonitoringIfNecessary(); - }) - .catch (error => { - raiseNotificationToast({ - text: { - translationKey: ResourceKeys.deviceEvents.error, - translationOptions: { - error - } - }, - type: NotificationType.error - }); - stopMonitoringIfNecessary(); - }); - }, - LOADING_LOCK); - } - }; - - const onClearData = () => { - SetEvents([]); - }; - - const stopMonitoringIfNecessary = () => { - if (appConfig.hostMode === HostMode.Electron) { - return; - } - else { - stopMonitoring().then(() => { - setHasMore(false); - setMonitoringData(false); - setSynchronizationStatus(SynchronizationStatus.fetched); - }); - } - }; - - const handleClose = () => { - const path = pathname.replace(/\/ioTPlugAndPlayDetail\/events\/.*/, ``); - history.push(`${path}/?${ROUTE_PARAMS.DEVICE_ID}=${encodeURIComponent(deviceId)}`); - }; - - const getModelDefinitionAndSchema = (key: string): TelemetrySchema => { - const matchingSchema = telemetrySchema.filter(schema => schema.telemetryModelDefinition.name === key); - const telemetryModelDefinition = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].telemetryModelDefinition; - const parsedSchema = matchingSchema && matchingSchema.length !== 0 && matchingSchema[0].parsedSchema; - return { - parsedSchema, - telemetryModelDefinition - }; - }; - - if (isLoading) { - return ; - } - - return ( -
- {renderCommandBar()} - {telemetrySchema && telemetrySchema.length === 0 ? - : - <> - - {renderRawTelemetryToggle()} - {renderInfiniteScroll()} - - } - {loadingAnnounced} -
- ); -}; diff --git a/src/app/devices/pnp/components/digitalTwinDetail.tsx b/src/app/devices/pnp/components/digitalTwinDetail.tsx index 64cacda84..ebfe78f23 100644 --- a/src/app/devices/pnp/components/digitalTwinDetail.tsx +++ b/src/app/devices/pnp/components/digitalTwinDetail.tsx @@ -13,7 +13,7 @@ import { DeviceSettings } from './deviceSettings/deviceSettings'; import { DeviceProperties } from './deviceProperties/deviceProperties'; import { DeviceCommands } from './deviceCommands/deviceCommands'; import { DeviceInterfaces } from './deviceInterfaces/deviceInterfaces'; -import { DeviceEventsPerInterface } from './deviceEvents/deviceEventsPerInterface'; +import { DeviceEvents } from '../../deviceEvents/components/deviceEvents'; import { getDeviceIdFromQueryString, getInterfaceIdFromQueryString, getComponentNameFromQueryString } from '../../../shared/utils/queryStringHelper'; import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext'; import '../../../css/_pivotHeader.scss'; @@ -56,7 +56,7 @@ export const DigitalTwinDetail: React.FC = () => { - + ); }; diff --git a/src/localization/locales/en.json b/src/localization/locales/en.json index 516b685cc..0f3504fa6 100644 --- a/src/localization/locales/en.json +++ b/src/localization/locales/en.json @@ -815,7 +815,9 @@ "deleteModuleIdentityOnSuccess": "Successfully deleted module {{moduleId}}", "deleteModuleIdentityOnError": "Failed to delete module identity {{moduleId}}: {{error}}", "portIsInUseError": "The port {{portNumber}} is in use. To configure a custom port value, set system environment variable 'AZURE_IOT_EXPLORER_PORT' to an available port number. To learn more, visit https://github.com/Azure/azure-iot-explorer/wiki/FAQ ", - "modelRepoistorySettingsUpdated": "Model repository locations successfully updated." + "modelRepoistorySettingsUpdated": "Model repository locations successfully updated.", + "startEventMonitoringOnError": "Faile to start monitoring device telemetry: {{error}}", + "stopEventMonitoringOnError": "Faile to stop monitoring device telemetry: {{error}}" }, "errorBoundary": { "text": "Something went wrong" diff --git a/src/localization/resourceKeys.ts b/src/localization/resourceKeys.ts index 3edfba97f..efc8647ba 100644 --- a/src/localization/resourceKeys.ts +++ b/src/localization/resourceKeys.ts @@ -851,6 +851,8 @@ export class ResourceKeys { portIsInUseError : "notifications.portIsInUseError", savedToIotHubConnectionString : "notifications.savedToIotHubConnectionString", sendingCloudToDeviceMessage : "notifications.sendingCloudToDeviceMessage", + startEventMonitoringOnError : "notifications.startEventMonitoringOnError", + stopEventMonitoringOnError : "notifications.stopEventMonitoringOnError", updateDeviceOnError : "notifications.updateDeviceOnError", updateDeviceOnSucceed : "notifications.updateDeviceOnSucceed", updateDeviceTwinOnError : "notifications.updateDeviceTwinOnError", diff --git a/src/server/serverBase.ts b/src/server/serverBase.ts index a739cd713..15098d7f8 100644 --- a/src/server/serverBase.ts +++ b/src/server/serverBase.ts @@ -342,7 +342,7 @@ export const eventHubProvider = async (res: any, body: any) => { // tslint:disa res.status(NOT_FOUND).send('Nothing to return'); } - return handleMessages(body.deviceId, client, hubInfo, partitionIds, startTime, !!body.fetchSystemProperties, body.consumerGroup); + return handleMessages(body.deviceId, client, hubInfo, partitionIds, startTime, body.consumerGroup); } else { res.status(CONFLICT).send('Client currently stopping'); } @@ -376,7 +376,7 @@ export const stopClient = async () => { }); }; -const handleMessages = async (deviceId: string, eventHubClient: EventHubClient, hubInfo: EventHubRuntimeInformation, partitionIds: string[], startTime: number, fetchSystemProperties: boolean, consumerGroup: string) => { +const handleMessages = async (deviceId: string, eventHubClient: EventHubClient, hubInfo: EventHubRuntimeInformation, partitionIds: string[], startTime: number, consumerGroup: string) => { const messages: Message[] = []; // tslint:disable-line: no-any const onMessage = async (eventData: any) => { // tslint:disable-line: no-any if (eventData && eventData.annotations && eventData.annotations[IOTHUB_CONNECTION_DEVICE_ID] === deviceId) { @@ -385,9 +385,7 @@ const handleMessages = async (deviceId: string, eventHubClient: EventHubClient, enqueuedTime: eventData.enqueuedTimeUtc, properties: eventData.applicationProperties }; - if (fetchSystemProperties) { - message.systemProperties = eventData.annotations; - } + message.systemProperties = eventData.annotations; messages.push(message); } };