Websocket (#580)
* try out websocket * try out websocket * websocket for events subscribe * remove duplicate messages
This commit is contained in:
Родитель
9d5fe5e490
Коммит
3f052f3f7f
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
12
package.json
12
package.json
|
@ -69,8 +69,8 @@
|
|||
},
|
||||
"homepage": "https://github.com/Azure/azure-iot-explorer#readme",
|
||||
"dependencies": {
|
||||
"@azure/core-amqp": "^3.2.0",
|
||||
"@azure/event-hubs": "1.0.7",
|
||||
"@azure/core-amqp": "3.2.0",
|
||||
"@azure/event-hubs": "5.6.0",
|
||||
"@azure/msal-node": "1.3.0",
|
||||
"@fluentui/react": "8.20.2",
|
||||
"@microsoft/applicationinsights-web": "2.8.4",
|
||||
|
@ -87,13 +87,12 @@
|
|||
"immutable": "4.0.0-rc.12",
|
||||
"jsonschema": "1.2.4",
|
||||
"msal": "1.2.0",
|
||||
"protobufjs": "^7.0.0",
|
||||
"protobufjs": "7.0.0",
|
||||
"react": "16.14.0",
|
||||
"react-collapsible": "2.3.2",
|
||||
"react-dom": "16.13.1",
|
||||
"react-i18next": "11.11.1",
|
||||
"react-jsonschema-form": "1.7.0",
|
||||
"react-qr-code": "^2.0.1",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-smooth-dnd": "0.11.0",
|
||||
"react-toastify": "4.4.0",
|
||||
|
@ -102,7 +101,8 @@
|
|||
"semver": "6.3.0",
|
||||
"typescript-fsa": "3.0.0-beta-2",
|
||||
"typescript-fsa-reducers": "1.0.0",
|
||||
"uuid": "3.3.3"
|
||||
"uuid": "3.3.3",
|
||||
"ws": "5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redux-saga/testing-utils": "1.1.3",
|
||||
|
@ -118,7 +118,7 @@
|
|||
"@types/react": "16.9.35",
|
||||
"@types/react-dom": "16.9.8",
|
||||
"@types/react-jsonschema-form": "1.0.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
"@types/request": "2.48.1",
|
||||
"@types/semver": "6.0.2",
|
||||
"@types/uuid": "3.4.5",
|
||||
|
|
|
@ -11,8 +11,6 @@ export const MESSAGE_CHANNELS = {
|
|||
AUTHENTICATION_LOGIN: 'authentication_login',
|
||||
AUTHENTICATION_LOGOUT: 'authentication_logout',
|
||||
DIRECTORY_GET_DIRECTORIES: 'directory_getDirectories',
|
||||
EVENTHUB_START_MONITORING: 'eventhub_startMonitoring',
|
||||
EVENTHUB_STOP_MONITORING: 'eventhub_stopMonitoring',
|
||||
MODEL_REPOSITORY_GET_DEFINITION: 'model_definition',
|
||||
SETTING_HIGH_CONTRAST: 'setting_highContrast',
|
||||
};
|
||||
|
@ -21,7 +19,6 @@ export const API_INTERFACES = {
|
|||
AUTHENTICATION: 'api_authentication',
|
||||
DEVICE: 'api_device',
|
||||
DIRECTORY: 'api_directory',
|
||||
EVENTHUB: 'api_eventhub',
|
||||
MODEL_DEFINITION: 'api_modelDefinition',
|
||||
SETTINGS: 'api_settings'
|
||||
};
|
||||
|
|
|
@ -6,12 +6,10 @@ import { contextBridge } from 'electron';
|
|||
import { generateSettingsInterface } from './factories/settingsInterfaceFactory';
|
||||
import { generateDirectoryInterface } from './factories/directoryInterfaceFactory';
|
||||
import { generateModelRepositoryInterface } from './factories/modelRepositoryInterfaceFactory';
|
||||
import { generateEventHubInterface } from './factories/eventHubInterfaceFactory';
|
||||
import { generateAuthenticationInterface } from './factories/authenticationInterfaceFactory';
|
||||
import { API_INTERFACES } from './constants';
|
||||
|
||||
contextBridge.exposeInMainWorld(API_INTERFACES.DIRECTORY, generateDirectoryInterface());
|
||||
contextBridge.exposeInMainWorld(API_INTERFACES.EVENTHUB, generateEventHubInterface());
|
||||
contextBridge.exposeInMainWorld(API_INTERFACES.MODEL_DEFINITION, generateModelRepositoryInterface());
|
||||
contextBridge.exposeInMainWorld(API_INTERFACES.SETTINGS, generateSettingsInterface());
|
||||
contextBridge.exposeInMainWorld(API_INTERFACES.AUTHENTICATION, generateAuthenticationInterface());
|
||||
|
|
|
@ -10,7 +10,6 @@ import { PLATFORMS, MESSAGE_CHANNELS } from './constants';
|
|||
import { onSettingsHighContrast } from './handlers/settingsHandler';
|
||||
import { onGetInterfaceDefinition } from './handlers/modelRepositoryHandler';
|
||||
import { onGetDirectories } from './handlers/directoryHandler';
|
||||
import { onStartMonitoring, onStopMonitoring } from './handlers/eventHubHandler';
|
||||
import { formatError } from './utils/errorHelper';
|
||||
import { AuthProvider } from './utils/authProvider';
|
||||
import '../dist/server/serverElectron';
|
||||
|
@ -33,8 +32,6 @@ class Main {
|
|||
Main.registerHandler(MESSAGE_CHANNELS.SETTING_HIGH_CONTRAST, onSettingsHighContrast);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.MODEL_REPOSITORY_GET_DEFINITION, onGetInterfaceDefinition);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.DIRECTORY_GET_DIRECTORIES, onGetDirectories);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.EVENTHUB_START_MONITORING, onStartMonitoring);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.EVENTHUB_STOP_MONITORING, onStopMonitoring);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_LOGIN, Main.onLogin);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_LOGOUT, Main.onLogout);
|
||||
Main.registerHandler(MESSAGE_CHANNELS.AUTHENTICATION_GET_PROFILE_TOKEN, Main.onGetProfileToken);
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { MESSAGE_CHANNELS } from '../constants';
|
||||
import { EventHubInterface, StartEventHubMonitoringParameters, Message } from '../interfaces/eventHubInterface';
|
||||
import { invokeInMainWorld } from '../utils/invokeHelper';
|
||||
|
||||
export const generateEventHubInterface = (): EventHubInterface => {
|
||||
return {
|
||||
startEventHubMonitoring: async (params: StartEventHubMonitoringParameters): Promise<Message[]> => {
|
||||
return invokeInMainWorld<Message[]>(MESSAGE_CHANNELS.EVENTHUB_START_MONITORING, params);
|
||||
},
|
||||
stopEventHubMonitoring: async (): Promise<void> => {
|
||||
return invokeInMainWorld<void>(MESSAGE_CHANNELS.EVENTHUB_STOP_MONITORING);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -1,46 +0,0 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { Message as CloudToDeviceMessage } from 'azure-iot-common';
|
||||
import { MessageProperty } from '../interfaces/deviceInterface';
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export const addPropertiesToCloudToDeviceMessage = (message: CloudToDeviceMessage, properties: MessageProperty[]) => {
|
||||
if (!properties || properties.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const property of properties) {
|
||||
if (property.isSystemProperty) {
|
||||
switch (property.key) {
|
||||
case 'ack':
|
||||
message.ack = property.value;
|
||||
break;
|
||||
case 'contentType':
|
||||
message.contentType = property.value as any; // tslint:disable-line:no-any
|
||||
break;
|
||||
case 'correlationId':
|
||||
message.correlationId = property.value;
|
||||
break;
|
||||
case 'contentEncoding':
|
||||
message.contentEncoding = property.value as any; // tslint:disable-line:no-any
|
||||
break;
|
||||
case 'expiryTimeUtc':
|
||||
message.expiryTimeUtc = parseInt(property.value); // tslint:disable-line:radix
|
||||
break;
|
||||
case 'messageId':
|
||||
message.messageId = property.value;
|
||||
break;
|
||||
case 'lockToken':
|
||||
message.lockToken = property.value;
|
||||
break;
|
||||
default:
|
||||
message.properties.add(property.key, property.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
message.properties.add(property.key, property.value);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,162 +0,0 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { IpcMainInvokeEvent } from 'electron';
|
||||
import { EventHubClient, EventPosition, ReceiveHandler } from '@azure/event-hubs';
|
||||
import { Message, StartEventHubMonitoringParameters } from '../interfaces/eventHubInterface';
|
||||
import { convertIotHubToEventHubsConnectionString } from '../utils/connStringHelper';
|
||||
|
||||
let client: EventHubClient = null;
|
||||
let messages: Message[] = [];
|
||||
let receivers: ReceiveHandler[] = [];
|
||||
let connectionString: string = ''; // would equal `${hubConnectionString}` or `${customEventHubConnectionString}/${customEventHubName}`
|
||||
let deviceId: string = '';
|
||||
let moduleId: string = '';
|
||||
|
||||
const IOTHUB_CONNECTION_DEVICE_ID = 'iothub-connection-device-id';
|
||||
const IOTHUB_CONNECTION_MODULE_ID = 'iothub-connection-module-id';
|
||||
|
||||
export const onStartMonitoring = async (event: IpcMainInvokeEvent, params: StartEventHubMonitoringParameters): Promise<Message[]>=> {
|
||||
return eventHubProvider(params).then(result => {
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export const onStopMonitoring = async (): Promise<void> => {
|
||||
try {
|
||||
return stopClient();
|
||||
} catch (error) {
|
||||
// swallow the error as we set client to null anyways
|
||||
}
|
||||
}
|
||||
|
||||
const eventHubProvider = async (params: StartEventHubMonitoringParameters) => {
|
||||
await initializeEventHubClient(params);
|
||||
updateEntityIdIfNecessary(params);
|
||||
|
||||
return listeningToMessages(client, params);
|
||||
};
|
||||
|
||||
const initializeEventHubClient = async (params: StartEventHubMonitoringParameters) => {
|
||||
if (needToCreateNewEventHubClient(params))
|
||||
{
|
||||
// hub has changed, reinitialize client, receivers and mesages
|
||||
if (params.customEventHubConnectionString) {
|
||||
client = await EventHubClient.createFromConnectionString(params.customEventHubConnectionString, params.customEventHubName);
|
||||
}
|
||||
else {
|
||||
try {
|
||||
client = await EventHubClient.createFromConnectionString(await convertIotHubToEventHubsConnectionString(params.hubConnectionString));
|
||||
}
|
||||
catch {
|
||||
client = await EventHubClient.createFromIotHubConnectionString(params.hubConnectionString);
|
||||
}
|
||||
}
|
||||
|
||||
connectionString = params.customEventHubConnectionString ?
|
||||
`${params.customEventHubConnectionString}/${params.customEventHubName}` :
|
||||
params.hubConnectionString;
|
||||
receivers = [];
|
||||
messages = [];
|
||||
}
|
||||
};
|
||||
|
||||
const listeningToMessages = async (eventHubClient: EventHubClient, params: StartEventHubMonitoringParameters) => {
|
||||
if (params.startListeners || !receivers) {
|
||||
const partitionIds = await client.getPartitionIds();
|
||||
const hubInfo = await client.getHubRuntimeInformation();
|
||||
const startTime = params.startTime ? Date.parse(params.startTime) : Date.now();
|
||||
|
||||
partitionIds && partitionIds.forEach(async (partitionId: string) => {
|
||||
const receiveOptions = {
|
||||
consumerGroup: params.consumerGroup,
|
||||
enableReceiverRuntimeMetric: true,
|
||||
eventPosition: EventPosition.fromEnqueuedTime(startTime),
|
||||
name: `${hubInfo.path}_${partitionId}`,
|
||||
};
|
||||
|
||||
const receiver = eventHubClient.receive(
|
||||
partitionId,
|
||||
onMessageReceived,
|
||||
(err: object) => {},
|
||||
receiveOptions);
|
||||
receivers.push(receiver);
|
||||
});
|
||||
}
|
||||
|
||||
return handleMessages();
|
||||
};
|
||||
|
||||
const handleMessages = () => {
|
||||
let results: Message[] = [];
|
||||
messages.forEach(message => {
|
||||
if (!results.some(result => result.systemProperties?.['x-opt-sequence-number'] === message.systemProperties?.['x-opt-sequence-number'])) {
|
||||
// if user click stop/start too refrequently, it's possible duplicate receivers are created before the cleanup happens as it's async
|
||||
// remove duplicate messages before proper cleanup is finished
|
||||
results.push(message);
|
||||
}
|
||||
})
|
||||
messages = []; // empty the array everytime the result is returned
|
||||
return results;
|
||||
}
|
||||
|
||||
const stopClient = async () => {
|
||||
return stopReceivers().then(() => {
|
||||
return client && client.close().catch(error => {
|
||||
console.log(`client cleanup error: ${error}`); // swallow the error as we will cleanup anyways
|
||||
});
|
||||
}).finally (() => {
|
||||
client = null;
|
||||
receivers = [];
|
||||
});
|
||||
};
|
||||
|
||||
const stopReceivers = async () => {
|
||||
return Promise.all(
|
||||
receivers.map(receiver => {
|
||||
if (receiver && (receiver.isReceiverOpen === undefined || receiver.isReceiverOpen)) {
|
||||
return stopReceiver(receiver);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const stopReceiver = async (receiver: ReceiveHandler) => {
|
||||
receiver.stop().catch((err: object) => {
|
||||
throw new Error(`receivers cleanup error: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
const needToCreateNewEventHubClient = (parmas: StartEventHubMonitoringParameters): boolean => {
|
||||
return !client ||
|
||||
parmas.hubConnectionString && parmas.hubConnectionString !== connectionString ||
|
||||
parmas.customEventHubConnectionString && `${parmas.customEventHubConnectionString}/${parmas.customEventHubName}` !== connectionString;
|
||||
}
|
||||
|
||||
const updateEntityIdIfNecessary = (parmas: StartEventHubMonitoringParameters) => {
|
||||
if( !deviceId || parmas.deviceId !== deviceId) {
|
||||
deviceId = parmas.deviceId;
|
||||
messages = [];
|
||||
}
|
||||
if (parmas.moduleId !== moduleId) {
|
||||
moduleId = parmas.moduleId;
|
||||
messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
const onMessageReceived = async (eventData: any) => {
|
||||
if (eventData && eventData.annotations && eventData.annotations[IOTHUB_CONNECTION_DEVICE_ID] === deviceId) {
|
||||
if (!moduleId || eventData?.annotations?.[IOTHUB_CONNECTION_MODULE_ID] === moduleId) {
|
||||
const message: Message = {
|
||||
body: eventData.body,
|
||||
enqueuedTime: eventData.enqueuedTimeUtc.toString(),
|
||||
properties: eventData.applicationProperties
|
||||
};
|
||||
message.systemProperties = eventData.annotations;
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -7,9 +7,6 @@ export interface StartEventHubMonitoringParameters {
|
|||
moduleId: string;
|
||||
consumerGroup: string;
|
||||
startTime: string;
|
||||
startListeners: boolean;
|
||||
|
||||
customEventHubName?: string;
|
||||
customEventHubConnectionString?: string;
|
||||
hubConnectionString?: string;
|
||||
}
|
||||
|
@ -20,8 +17,3 @@ export interface Message {
|
|||
properties?: any; // tslint:disable-line:no-any
|
||||
systemProperties?: {[key: string]: string};
|
||||
}
|
||||
|
||||
export interface EventHubInterface {
|
||||
startEventHubMonitoring(params: StartEventHubMonitoringParameters): Promise<Message[]>;
|
||||
stopEventHubMonitoring(): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { Message, StartEventHubMonitoringParameters } from '../../../../public/interfaces/eventHubInterface';
|
||||
import { CONTROLLER_API_ENDPOINT, DataPlaneStatusCode, EVENTHUB, MONITOR, STOP } from '../../constants/apiConstants';
|
||||
import { request } from '../services/dataplaneServiceHelper';
|
||||
|
||||
const EVENTHUB_CONTROLLER_ENDPOINT = `${CONTROLLER_API_ENDPOINT}${EVENTHUB}`;
|
||||
export const EVENTHUB_MONITOR_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${MONITOR}`;
|
||||
export const EVENTHUB_STOP_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${STOP}`;
|
||||
|
||||
export const startEventHubMonitoring = async (params: StartEventHubMonitoringParameters): Promise<Message[]> => {
|
||||
const response = await request(EVENTHUB_MONITOR_ENDPOINT, params);
|
||||
if (response.status === DataPlaneStatusCode.SuccessLowerBound) {
|
||||
return await response.json() as Message[];
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
throw new Error(error && error.name);
|
||||
}
|
||||
};
|
||||
|
||||
export const stopEventHubMonitoring = async (): Promise<void> => {
|
||||
await request(EVENTHUB_STOP_ENDPOINT, {});
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { DeviceInterface, SendMessageToDeviceParameters } from '../../../../public/interfaces/deviceInterface';
|
||||
import { CONTROLLER_API_ENDPOINT, CLOUD_TO_DEVICE, EVENTHUB, MONITOR, STOP } from '../../constants/apiConstants';
|
||||
import { request } from '../services/dataplaneServiceHelper';
|
||||
|
||||
const EVENTHUB_CONTROLLER_ENDPOINT = `${CONTROLLER_API_ENDPOINT}${EVENTHUB}`;
|
||||
export const EVENTHUB_MONITOR_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${MONITOR}`;
|
||||
export const EVENTHUB_STOP_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${STOP}`;
|
||||
|
||||
export class DevicesServiceHandler implements DeviceInterface {
|
||||
public sendMessageToDevice = async (params: SendMessageToDeviceParameters): Promise<void> => {
|
||||
await request(`${CONTROLLER_API_ENDPOINT}${CLOUD_TO_DEVICE}`, params);
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { EventHubInterface, Message, StartEventHubMonitoringParameters } from '../../../../public/interfaces/eventHubInterface';
|
||||
import { CONTROLLER_API_ENDPOINT, DataPlaneStatusCode, EVENTHUB, MONITOR, STOP } from '../../constants/apiConstants';
|
||||
import { request } from '../services/dataplaneServiceHelper';
|
||||
|
||||
const EVENTHUB_CONTROLLER_ENDPOINT = `${CONTROLLER_API_ENDPOINT}${EVENTHUB}`;
|
||||
export const EVENTHUB_MONITOR_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${MONITOR}`;
|
||||
export const EVENTHUB_STOP_ENDPOINT = `${EVENTHUB_CONTROLLER_ENDPOINT}${STOP}`;
|
||||
|
||||
export class EventHubServiceHandler implements EventHubInterface {
|
||||
public startEventHubMonitoring = async (params: StartEventHubMonitoringParameters): Promise<Message[]> => {
|
||||
const response = await request(EVENTHUB_MONITOR_ENDPOINT, params);
|
||||
if (response.status === DataPlaneStatusCode.SuccessLowerBound) {
|
||||
return await response.json() as Message[];
|
||||
}
|
||||
else {
|
||||
const error = await response.json();
|
||||
throw new Error(error && error.name);
|
||||
}
|
||||
}
|
||||
|
||||
public stopEventHubMonitoring = async (): Promise<void> => {
|
||||
await request(EVENTHUB_STOP_ENDPOINT, {});
|
||||
}
|
||||
}
|
|
@ -29,9 +29,6 @@ export interface MonitorEventsParameters {
|
|||
deviceId: string;
|
||||
moduleId: string;
|
||||
consumerGroup: string;
|
||||
startListeners: boolean;
|
||||
|
||||
customEventHubName?: string;
|
||||
customEventHubConnectionString?: string;
|
||||
hubConnectionString?: string;
|
||||
startTime?: Date;
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Twin } from '../models/device';
|
|||
import { DeviceIdentity } from './../models/deviceIdentity';
|
||||
import { buildQueryString, getConnectionInfoFromConnectionString } from '../shared/utils';
|
||||
import { MonitorEventsParameters } from '../parameters/deviceParameters';
|
||||
import * as interfaceUtils from '../shared/interfaceUtils';
|
||||
import { EVENTHUB_MONITOR_ENDPOINT, EVENTHUB_STOP_ENDPOINT } from '../handlers/eventHubServiceHandler';
|
||||
|
||||
const deviceId = 'deviceId';
|
||||
const connectionString = 'HostName=test-string.azure-devices.net;SharedAccessKeyName=owner;SharedAccessKey=fakeKey=';
|
||||
|
@ -59,6 +59,9 @@ const mockDataPlaneConnectionHelper = () => {
|
|||
};
|
||||
|
||||
describe('deviceTwinService', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
context('fetchDeviceTwin', () => {
|
||||
it ('returns if deviceId is not specified', () => {
|
||||
|
@ -391,6 +394,7 @@ describe('deviceTwinService', () => {
|
|||
const dataPlaneRequest: DataplaneService.DataPlaneRequest = {
|
||||
apiVersion: HUB_DATA_PLANE_API_VERSION,
|
||||
body: JSON.stringify(deviceIdentity),
|
||||
headers: {'If-Match': `"null"`},
|
||||
hostName: connectionInformation.connectionInfo.hostName,
|
||||
httpMethod: HTTP_OPERATION_TYPES.Put,
|
||||
path: `devices/${deviceId}`,
|
||||
|
@ -631,45 +635,76 @@ describe('deviceTwinService', () => {
|
|||
context('monitorEvents', () => {
|
||||
const parameters: MonitorEventsParameters = {
|
||||
consumerGroup: '$Default',
|
||||
customEventHubConnectionString: undefined,
|
||||
customEventHubConnectionString: 'customConnectionString',
|
||||
deviceId,
|
||||
hubConnectionString: undefined,
|
||||
startListeners: true,
|
||||
moduleId: undefined,
|
||||
startTime: undefined
|
||||
moduleId: ''
|
||||
};
|
||||
|
||||
it('calls startEventHubMonitoring with expected parameters', async () => {
|
||||
jest.spyOn(DataplaneService, 'dataPlaneConnectionHelper').mockResolvedValue({
|
||||
connectionInfo: getConnectionInfoFromConnectionString(connectionString), connectionString, sasToken});
|
||||
|
||||
const startEventHubMonitoring = jest.fn();
|
||||
jest.spyOn(interfaceUtils, 'getEventHubInterface').mockReturnValue({
|
||||
startEventHubMonitoring,
|
||||
stopEventHubMonitoring: jest.fn()
|
||||
});
|
||||
it('calls fetch with specified parameters', async () => {
|
||||
// tslint:disable
|
||||
const response = {
|
||||
json: () => {
|
||||
return {
|
||||
headers:{}
|
||||
}
|
||||
},
|
||||
status: 200
|
||||
} as any;
|
||||
// tslint:enable
|
||||
jest.spyOn(window, 'fetch').mockResolvedValue(response);
|
||||
|
||||
await DevicesService.monitorEvents(parameters);
|
||||
expect(startEventHubMonitoring).toBeCalledWith({
|
||||
...parameters,
|
||||
hubConnectionString: connectionString,
|
||||
startTime: parameters.startTime && parameters.startTime.toISOString()
|
||||
expect(fetch).toBeCalledWith(EVENTHUB_MONITOR_ENDPOINT, {
|
||||
body: JSON.stringify(parameters),
|
||||
cache: 'no-cache',
|
||||
credentials: 'include',
|
||||
headers: new Headers({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
method: HTTP_OPERATION_TYPES.Post,
|
||||
mode: 'cors',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws Error when promise rejects', async () => {
|
||||
window.fetch = jest.fn().mockRejectedValueOnce(new Error());
|
||||
await expect(DevicesService.monitorEvents(parameters)).rejects.toThrow(new Error());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
context('stopMonitoringEvents', () => {
|
||||
it('calls stopEventHubMonitoring', async () => {
|
||||
|
||||
const stopEventHubMonitoring = jest.fn();
|
||||
jest.spyOn(interfaceUtils, 'getEventHubInterface').mockReturnValue({
|
||||
startEventHubMonitoring: jest.fn(),
|
||||
stopEventHubMonitoring
|
||||
});
|
||||
it('calls fetch with specified parameters', async () => {
|
||||
// tslint:disable
|
||||
const response = {
|
||||
json: () => {
|
||||
return {
|
||||
headers:{}
|
||||
}
|
||||
},
|
||||
status: 200
|
||||
} as any;
|
||||
// tslint:enable
|
||||
jest.spyOn(window, 'fetch').mockResolvedValue(response);
|
||||
|
||||
await DevicesService.stopMonitoringEvents();
|
||||
expect(stopEventHubMonitoring).toBeCalled();
|
||||
expect(fetch).toBeCalledWith(EVENTHUB_STOP_ENDPOINT, {
|
||||
body: JSON.stringify({}),
|
||||
cache: 'no-cache',
|
||||
credentials: 'include',
|
||||
headers: new Headers({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
method: HTTP_OPERATION_TYPES.Post,
|
||||
mode: 'cors',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws Error when promise rejects', async () => {
|
||||
window.fetch = jest.fn().mockRejectedValueOnce(new Error());
|
||||
await expect(DevicesService.stopMonitoringEvents()).rejects.toThrow(new Error());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { Type } from 'protobufjs';
|
||||
import {
|
||||
CloudToDeviceMessageParameters,
|
||||
FetchDeviceTwinParameters,
|
||||
|
@ -23,11 +24,10 @@ import { Message } from '../models/messages';
|
|||
import { Twin, Device, DataPlaneResponse } from '../models/device';
|
||||
import { DeviceIdentity } from '../models/deviceIdentity';
|
||||
import { dataPlaneConnectionHelper, dataPlaneResponseHelper, request, DATAPLANE_CONTROLLER_ENDPOINT, DataPlaneRequest } from './dataplaneServiceHelper';
|
||||
import { getEventHubInterface } from '../shared/interfaceUtils';
|
||||
import { parseEventHubMessage } from './eventHubMessageHelper';
|
||||
import { HttpError } from '../models/httpError';
|
||||
import { AppInsightsClient } from '../../shared/appTelemetry/appInsightsClient';
|
||||
import { TELEMETRY_EVENTS } from '../../constants/telemetry';
|
||||
import { startEventHubMonitoring, stopEventHubMonitoring } from '../handlers/eventHubServiceHandler';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
|
@ -251,15 +251,14 @@ export const deleteDevices = async (parameters: DeleteDevicesParameters) => {
|
|||
return result && result.body;
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export const monitorEvents = async (parameters: MonitorEventsParameters): Promise<Message[]> => {
|
||||
export const monitorEvents = async (parameters: MonitorEventsParameters): Promise<void> => {
|
||||
let requestParameters = {
|
||||
...parameters,
|
||||
startTime: parameters.startTime && parameters.startTime.toISOString()
|
||||
};
|
||||
|
||||
// if no custom event hub info is provided, use default hub connection string to connect to event hub
|
||||
if (!parameters.customEventHubConnectionString || !parameters.customEventHubName) {
|
||||
if (!parameters.customEventHubConnectionString) {
|
||||
const connectionInfo = await dataPlaneConnectionHelper();
|
||||
requestParameters = {
|
||||
...requestParameters,
|
||||
|
@ -267,12 +266,14 @@ export const monitorEvents = async (parameters: MonitorEventsParameters): Promis
|
|||
};
|
||||
}
|
||||
|
||||
const api = getEventHubInterface();
|
||||
const result = await api.startEventHubMonitoring(requestParameters);
|
||||
return result && result.length && result.length !== 0 && Promise.all(result.map(message => parseEventHubMessage(message, parameters.decoderPrototype)) || []);
|
||||
await startEventHubMonitoring(requestParameters);
|
||||
};
|
||||
|
||||
export const parseEvents = async (params: {messages: Message[], decoderPrototype?: Type}): Promise<Message[]> => {
|
||||
const { messages, decoderPrototype } = params;
|
||||
return Promise.all(messages?.map(message => parseEventHubMessage(message, decoderPrototype)) || []);
|
||||
};
|
||||
|
||||
export const stopMonitoringEvents = async (): Promise<void> => {
|
||||
const api = getEventHubInterface();
|
||||
await api.stopEventHubMonitoring();
|
||||
await stopEventHubMonitoring();
|
||||
};
|
||||
|
|
|
@ -49,16 +49,6 @@ describe('browserSettingsApi', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getDeviceInterface', () => {
|
||||
it('calls expected factory when mode is electron', () => {
|
||||
appConfig.hostMode = HostMode.Electron;
|
||||
const factory = jest.spyOn(interfaceUtils, 'getElectronInterface');
|
||||
|
||||
interfaceUtils.getDeviceInterface();
|
||||
expect(factory).toHaveBeenLastCalledWith(API_INTERFACES.DEVICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDirectoryInterface', () => {
|
||||
it('calls expected factory when mode is electron', () => {
|
||||
appConfig.hostMode = HostMode.Electron;
|
||||
|
@ -79,12 +69,3 @@ describe('getLocalModelRepositoryInterface', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getEventHubInterface', () => {
|
||||
it('calls expected factory when mode is electron', () => {
|
||||
appConfig.hostMode = HostMode.Electron;
|
||||
const factory = jest.spyOn(interfaceUtils, 'getElectronInterface');
|
||||
|
||||
interfaceUtils.getEventHubInterface()
|
||||
expect(factory).toHaveBeenLastCalledWith(API_INTERFACES.EVENTHUB);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,17 +3,13 @@
|
|||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { SettingsInterface } from '../../../../public/interfaces/settingsInterface';
|
||||
import { DeviceInterface } from '../../../../public/interfaces/deviceInterface';
|
||||
import { DirectoryInterface } from '../../../../public/interfaces/directoryInterface';
|
||||
import { ModelRepositoryInterface } from '../../../../public/interfaces/modelRepositoryInterface';
|
||||
import { EventHubInterface } from './../../../../public/interfaces/eventHubInterface';
|
||||
import { AuthenticationInterface } from './../../../../public/interfaces/authenticationInterface';
|
||||
import { API_INTERFACES } from '../../../../public/constants';
|
||||
import { appConfig, HostMode } from '../../../appConfig/appConfig';
|
||||
import { HIGH_CONTRAST } from '../../constants/browserStorage';
|
||||
import { LocalRepoServiceHandler } from '../legacy/localRepoServiceHandler';
|
||||
import { DevicesServiceHandler } from '../legacy/devicesServiceHandler';
|
||||
import { EventHubServiceHandler } from '../legacy/eventHubServiceHandler';
|
||||
import { LocalRepoServiceHandler } from '../handlers/localRepoServiceHandler';
|
||||
import { PublicDigitalTwinsModelRepoHelper, PublicDigitalTwinsModelInterface } from '../services/publicDigitalTwinsModelRepoHelper';
|
||||
|
||||
export const NOT_AVAILABLE = 'Feature is not available in this configuration';
|
||||
|
@ -33,13 +29,6 @@ export const getSettingsInterfaceForBrowser = (): SettingsInterface => {
|
|||
});
|
||||
};
|
||||
|
||||
export const getDeviceInterface = (): DeviceInterface => {
|
||||
if (appConfig.hostMode !== HostMode.Electron) {
|
||||
return new DevicesServiceHandler();
|
||||
}
|
||||
return getElectronInterface(API_INTERFACES.DEVICE);
|
||||
};
|
||||
|
||||
export const getLocalModelRepositoryInterface = (): ModelRepositoryInterface => {
|
||||
if (appConfig.hostMode !== HostMode.Electron) {
|
||||
return new LocalRepoServiceHandler();
|
||||
|
@ -56,14 +45,6 @@ export const getDirectoryInterface = (): DirectoryInterface => {
|
|||
return getElectronInterface(API_INTERFACES.DIRECTORY);
|
||||
};
|
||||
|
||||
export const getEventHubInterface = (): EventHubInterface => {
|
||||
if (appConfig.hostMode !== HostMode.Electron) {
|
||||
return new EventHubServiceHandler();
|
||||
}
|
||||
|
||||
return getElectronInterface(API_INTERFACES.EVENTHUB);
|
||||
};
|
||||
|
||||
export const getPublicDigitalTwinsModelInterface = (): PublicDigitalTwinsModelInterface => {
|
||||
return new PublicDigitalTwinsModelRepoHelper();
|
||||
};
|
||||
|
|
|
@ -29,6 +29,7 @@ export const SET_DECODE_INFO = 'SET_DECODE_INFO';
|
|||
export const UPDATE_TWIN = 'UPDATE_TWIN';
|
||||
export const UPDATE_DEVICE_IDENTITY = 'UPDATE_DEVICE_IDENTITY';
|
||||
export const SET_DEFAULT_DECODE_INFO = 'SET_DEFAULT_DECODE_INFO';
|
||||
export const SET_EVENTS_MESSAGES = 'SET_EVENTS_MESSAGES';
|
||||
|
||||
// module identity
|
||||
export const ADD_MODULE_IDENTITY = 'ADD_MODULE_IDENTITY';
|
||||
|
|
|
@ -9,7 +9,7 @@ import { CUSTOM_CONTROLLER_PORT } from './browserStorage';
|
|||
export const DATAPLANE = '/DataPlane';
|
||||
export const EVENTHUB = '/EventHub';
|
||||
export const MODELREPO = '/ModelRepo';
|
||||
export const CLOUD_TO_DEVICE = '/CloudToDevice';
|
||||
|
||||
export const READ_FILE = '/ReadFile';
|
||||
export const GET_DIRECTORIES = '/Directories';
|
||||
export const DEFAULT_DIRECTORY = '$DEFAULT';
|
||||
|
@ -55,6 +55,7 @@ export enum DataPlaneStatusCode {
|
|||
|
||||
export const DEFAULT_CONSUMER_GROUP = '$Default';
|
||||
|
||||
const wsIp = 'ws://127.0.0.1';
|
||||
const localIp = 'http://127.0.0.1';
|
||||
const apiPath = '/api';
|
||||
|
||||
|
@ -71,6 +72,8 @@ export const CONTROLLER_API_ENDPOINT =
|
|||
`${localIp}:${appConfig.controllerPort}${apiPath}` :
|
||||
`${localIp}:${getPort()}${apiPath}`;
|
||||
|
||||
export const WEBSOCKET_ENDPOINT = `${wsIp}:${getPort()}`;
|
||||
|
||||
export enum HTTP_OPERATION_TYPES {
|
||||
Delete = 'DELETE',
|
||||
Get = 'GET',
|
||||
|
|
|
@ -4,14 +4,15 @@
|
|||
**********************************************************/
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
import { DEVICECONTENT } from '../../constants/actionPrefixes';
|
||||
import { CLEAR_MONITORING_EVENTS, SET_DEFAULT_DECODE_INFO, SET_DECODE_INFO, START_EVENTS_MONITORING, STOP_EVENTS_MONITORING } from '../../constants/actionTypes';
|
||||
import { CLEAR_MONITORING_EVENTS, SET_DEFAULT_DECODE_INFO, SET_DECODE_INFO, START_EVENTS_MONITORING, STOP_EVENTS_MONITORING, SET_EVENTS_MESSAGES } from '../../constants/actionTypes';
|
||||
import { Message } from '../../api/models/messages';
|
||||
import { MonitorEventsParameters, SetDecoderInfoParameters } from '../../api/parameters/deviceParameters';
|
||||
import { ContentTypeState } from './state';
|
||||
|
||||
const deviceContentCreator = actionCreatorFactory(DEVICECONTENT);
|
||||
export const startEventsMonitoringAction = deviceContentCreator.async<MonitorEventsParameters, Message[]>(START_EVENTS_MONITORING);
|
||||
export const startEventsMonitoringAction = deviceContentCreator.async<MonitorEventsParameters, void>(START_EVENTS_MONITORING);
|
||||
export const stopEventsMonitoringAction = deviceContentCreator.async<void, void>(STOP_EVENTS_MONITORING);
|
||||
export const clearMonitoringEventsAction = deviceContentCreator(CLEAR_MONITORING_EVENTS);
|
||||
export const setDecoderInfoAction = deviceContentCreator.async<SetDecoderInfoParameters, ContentTypeState>(SET_DECODE_INFO);
|
||||
export const setDefaultDecodeInfoAction = deviceContentCreator(SET_DEFAULT_DECODE_INFO);
|
||||
export const setEventsMessagesAction = deviceContentCreator.async<Message[], Message[]>(SET_EVENTS_MESSAGES);
|
||||
|
|
|
@ -88,13 +88,13 @@ exports[`commands matches snapshot in hosted environment 1`] = `
|
|||
items={
|
||||
Array [
|
||||
Object {
|
||||
"ariaLabel": "deviceEvents.command.fetch",
|
||||
"ariaLabel": "deviceEvents.command.start",
|
||||
"disabled": false,
|
||||
"iconProps": Object {
|
||||
"iconName": "Play",
|
||||
},
|
||||
"key": "Play",
|
||||
"name": "deviceEvents.command.fetch",
|
||||
"name": "deviceEvents.command.start",
|
||||
"onClick": [Function],
|
||||
},
|
||||
Object {
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ConsumerGroup matches snapshot 1`] = `
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="deviceEvents.consumerGroups.label"
|
||||
className="consumer-group-text-field"
|
||||
disabled={true}
|
||||
label="deviceEvents.consumerGroups.label"
|
||||
onChange={[Function]}
|
||||
onRenderLabel={[Function]}
|
||||
underlined={true}
|
||||
value="$Default"
|
||||
/>
|
||||
`;
|
|
@ -28,15 +28,7 @@ exports[`customEventHub matches snapshot 1`] = `
|
|||
placeholder="deviceEvents.customEventHub.connectionString.placeHolder"
|
||||
required={true}
|
||||
underlined={true}
|
||||
/>
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="deviceEvents.customEventHub.name.label"
|
||||
className="custom-text-field"
|
||||
disabled={true}
|
||||
label="deviceEvents.customEventHub.name.label"
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
underlined={true}
|
||||
value=""
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`deviceEvents deviceEvents in non-pnp context matches snapshot 1`] = `
|
||||
exports[`deviceEvents matches snapshot after loaded 1`] = `
|
||||
<Stack
|
||||
className="device-events"
|
||||
key="device-events"
|
||||
|
@ -18,6 +18,7 @@ exports[`deviceEvents deviceEvents in non-pnp context matches snapshot 1`] = `
|
|||
showSimulationPanel={false}
|
||||
showSystemProperties={false}
|
||||
startDisabled={false}
|
||||
stopFetching={[Function]}
|
||||
/>
|
||||
<HeaderView
|
||||
headerText="deviceEvents.headerText"
|
||||
|
@ -45,10 +46,9 @@ exports[`deviceEvents deviceEvents in non-pnp context matches snapshot 1`] = `
|
|||
<CustomEventHub
|
||||
monitoringData={false}
|
||||
setCustomEventHubConnectionString={[Function]}
|
||||
setCustomEventHubName={[Function]}
|
||||
setHasError={[Function]}
|
||||
setUseBuiltInEventHub={[Function]}
|
||||
useBuiltInEventHub={false}
|
||||
useBuiltInEventHub={true}
|
||||
/>
|
||||
</div>
|
||||
<DeviceSimulationPanel
|
||||
|
@ -62,168 +62,15 @@ exports[`deviceEvents deviceEvents in non-pnp context matches snapshot 1`] = `
|
|||
<div
|
||||
className="device-events-container"
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<article
|
||||
className="device-events-content"
|
||||
key="0"
|
||||
>
|
||||
<h5>
|
||||
Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)
|
||||
:
|
||||
</h5>
|
||||
<pre>
|
||||
{
|
||||
"body": {
|
||||
"humid": 123
|
||||
},
|
||||
"enqueuedTime": "Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)",
|
||||
"properties": {
|
||||
"iothub-message-schema": "humid"
|
||||
}
|
||||
}
|
||||
</pre>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`deviceEvents deviceEvents in pnp context matches snapshot while interface cannot be found 1`] = `
|
||||
<Stack
|
||||
className="device-events"
|
||||
key="device-events"
|
||||
>
|
||||
<Commands
|
||||
fetchData={[Function]}
|
||||
monitoringData={false}
|
||||
setMonitoringData={[Function]}
|
||||
setShowContentTypePanel={[Function]}
|
||||
setShowPnpModeledEvents={[Function]}
|
||||
setShowSimulationPanel={[Function]}
|
||||
setShowSystemProperties={[Function]}
|
||||
showContentTypePanel={false}
|
||||
showPnpModeledEvents={true}
|
||||
showSimulationPanel={false}
|
||||
showSystemProperties={false}
|
||||
startDisabled={false}
|
||||
/>
|
||||
<HeaderView
|
||||
headerText="deviceEvents.headerText"
|
||||
tooltip="deviceEvents.tooltip"
|
||||
/>
|
||||
<div
|
||||
className="horizontal-item"
|
||||
>
|
||||
<ConsumerGroup
|
||||
consumerGroup="$Default"
|
||||
<Loader
|
||||
monitoringData={false}
|
||||
setConsumerGroup={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<StartTime
|
||||
monitoringData={false}
|
||||
setHasError={[Function]}
|
||||
setSpecifyStartTime={[Function]}
|
||||
setStartTime={[Function]}
|
||||
specifyStartTime={false}
|
||||
/>
|
||||
<div
|
||||
className="horizontal-item"
|
||||
>
|
||||
<CustomEventHub
|
||||
monitoringData={false}
|
||||
setCustomEventHubConnectionString={[Function]}
|
||||
setCustomEventHubName={[Function]}
|
||||
setHasError={[Function]}
|
||||
setUseBuiltInEventHub={[Function]}
|
||||
useBuiltInEventHub={false}
|
||||
/>
|
||||
</div>
|
||||
<DeviceSimulationPanel
|
||||
onToggleSimulationPanel={[Function]}
|
||||
showSimulationPanel={false}
|
||||
/>
|
||||
<DeviceContentTypePanel
|
||||
onToggleContentTypePanel={[Function]}
|
||||
showContentTypePanel={false}
|
||||
/>
|
||||
<div
|
||||
className="device-events-container"
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
<EventsContent
|
||||
showPnpModeledEvents={false}
|
||||
showSystemProperties={false}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`deviceEvents deviceEvents in pnp context matches snapshot while interface definition is retrieved 1`] = `
|
||||
<Stack
|
||||
className="device-events"
|
||||
key="device-events"
|
||||
>
|
||||
<Commands
|
||||
fetchData={[Function]}
|
||||
monitoringData={false}
|
||||
setMonitoringData={[Function]}
|
||||
setShowContentTypePanel={[Function]}
|
||||
setShowPnpModeledEvents={[Function]}
|
||||
setShowSimulationPanel={[Function]}
|
||||
setShowSystemProperties={[Function]}
|
||||
showContentTypePanel={false}
|
||||
showPnpModeledEvents={true}
|
||||
showSimulationPanel={false}
|
||||
showSystemProperties={false}
|
||||
startDisabled={false}
|
||||
/>
|
||||
<HeaderView
|
||||
headerText="deviceEvents.headerText"
|
||||
tooltip="deviceEvents.tooltip"
|
||||
/>
|
||||
<div
|
||||
className="horizontal-item"
|
||||
>
|
||||
<ConsumerGroup
|
||||
consumerGroup="$Default"
|
||||
monitoringData={false}
|
||||
setConsumerGroup={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<StartTime
|
||||
monitoringData={false}
|
||||
setHasError={[Function]}
|
||||
setSpecifyStartTime={[Function]}
|
||||
setStartTime={[Function]}
|
||||
specifyStartTime={false}
|
||||
/>
|
||||
<div
|
||||
className="horizontal-item"
|
||||
>
|
||||
<CustomEventHub
|
||||
monitoringData={false}
|
||||
setCustomEventHubConnectionString={[Function]}
|
||||
setCustomEventHubName={[Function]}
|
||||
setHasError={[Function]}
|
||||
setUseBuiltInEventHub={[Function]}
|
||||
useBuiltInEventHub={false}
|
||||
/>
|
||||
</div>
|
||||
<DeviceSimulationPanel
|
||||
onToggleSimulationPanel={[Function]}
|
||||
showSimulationPanel={false}
|
||||
/>
|
||||
<DeviceContentTypePanel
|
||||
onToggleContentTypePanel={[Function]}
|
||||
showContentTypePanel={false}
|
||||
/>
|
||||
<div
|
||||
className="device-events-container"
|
||||
>
|
||||
<div
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
`;
|
||||
exports[`deviceEvents matches snapshot when loading 1`] = `<MultiLineShimmer />`;
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EventsContent EventsContent in non-pnp context matches snapshot 1`] = `
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<article
|
||||
className="device-events-content"
|
||||
key="0"
|
||||
>
|
||||
<h5>
|
||||
Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)
|
||||
:
|
||||
</h5>
|
||||
<pre>
|
||||
{
|
||||
"body": {
|
||||
"humid": 123
|
||||
},
|
||||
"enqueuedTime": "Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)",
|
||||
"properties": {
|
||||
"iothub-message-schema": "humid"
|
||||
}
|
||||
}
|
||||
</pre>
|
||||
</article>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`EventsContent EventsContent in pnp context matches snapshot while interface definition is retrieved 1`] = `
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<div
|
||||
className="pnp-modeled-list"
|
||||
>
|
||||
<div
|
||||
className="list-header list-header-uncollapsible flex-grid-row"
|
||||
>
|
||||
<span
|
||||
className="col-sm2"
|
||||
>
|
||||
deviceEvents.columns.timestamp
|
||||
</span>
|
||||
<span
|
||||
className="col-sm2"
|
||||
>
|
||||
deviceEvents.columns.displayName
|
||||
</span>
|
||||
<span
|
||||
className="col-sm2"
|
||||
>
|
||||
deviceEvents.columns.schema
|
||||
</span>
|
||||
<span
|
||||
className="col-sm2"
|
||||
>
|
||||
deviceEvents.columns.unit
|
||||
</span>
|
||||
<span
|
||||
className="col-sm4"
|
||||
>
|
||||
deviceEvents.columns.value
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
role="feed"
|
||||
>
|
||||
<article
|
||||
className="list-item event-list-item"
|
||||
key="0"
|
||||
role="article"
|
||||
>
|
||||
<section
|
||||
className="flex-grid-row item-summary"
|
||||
>
|
||||
<ErrorBoundary
|
||||
error="errorBoundary.text"
|
||||
>
|
||||
<div
|
||||
className="col-sm2"
|
||||
>
|
||||
<StyledLabelBase
|
||||
aria-label="deviceEvents.columns.timestamp"
|
||||
>
|
||||
Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)
|
||||
</StyledLabelBase>
|
||||
</div>
|
||||
<div
|
||||
className="col-sm2"
|
||||
>
|
||||
<StyledLabelBase
|
||||
aria-label="deviceEvents.columns.displayName"
|
||||
>
|
||||
--
|
||||
</StyledLabelBase>
|
||||
</div>
|
||||
<div
|
||||
className="col-sm2"
|
||||
>
|
||||
<StyledLabelBase
|
||||
aria-label="deviceEvents.columns.schema"
|
||||
>
|
||||
--
|
||||
</StyledLabelBase>
|
||||
</div>
|
||||
<div
|
||||
className="col-sm2"
|
||||
>
|
||||
<StyledLabelBase
|
||||
aria-label="deviceEvents.columns.unit"
|
||||
>
|
||||
<SemanticUnit />
|
||||
</StyledLabelBase>
|
||||
</div>
|
||||
<div
|
||||
className="column-value-text col-sm4"
|
||||
>
|
||||
<StyledLabelBase
|
||||
aria-label="deviceEvents.columns.value"
|
||||
>
|
||||
{
|
||||
"humid": 123
|
||||
}
|
||||
</StyledLabelBase>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Loader matches snapshot when loading 1`] = `
|
||||
<Fragment>
|
||||
<StyledMessageBar
|
||||
messageBarType={0}
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
Object {
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div>
|
||||
deviceEvents.infiniteScroll.loading
|
||||
</div>
|
||||
<StyledSpinnerBase
|
||||
size={1}
|
||||
/>
|
||||
</Stack>
|
||||
</StyledMessageBar>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`Loader matches snapshot when not loading 1`] = `<Fragment />`;
|
|
@ -38,6 +38,7 @@ describe('commands', () => {
|
|||
setShowPnpModeledEvents={jest.fn()}
|
||||
setShowSimulationPanel={jest.fn()}
|
||||
fetchData={jest.fn()}
|
||||
stopFetching={jest.fn()}
|
||||
/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -59,6 +60,7 @@ describe('commands', () => {
|
|||
setShowPnpModeledEvents={jest.fn()}
|
||||
setShowSimulationPanel={jest.fn()}
|
||||
fetchData={jest.fn()}
|
||||
stopFetching={jest.fn()}
|
||||
/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -82,6 +84,7 @@ describe('commands', () => {
|
|||
setShowPnpModeledEvents={mockSetShowPnpModeledEvents}
|
||||
setShowSimulationPanel={jest.fn()}
|
||||
fetchData={jest.fn()}
|
||||
stopFetching={jest.fn()}
|
||||
/>);
|
||||
|
||||
expect(wrapper.find(CommandBar).props().items.length).toEqual(5);
|
||||
|
|
|
@ -8,14 +8,13 @@ import { useLocation, useHistory } from 'react-router-dom';
|
|||
import { CommandBar, ICommandBarItemProps } from '@fluentui/react';
|
||||
import { ResourceKeys } from '../../../../localization/resourceKeys';
|
||||
import { CLEAR, CHECKED_CHECKBOX, EMPTY_CHECKBOX, START, STOP, NAVIGATE_BACK, REFRESH, REMOVE, CODE, UPLOAD } from '../../../constants/iconNames';
|
||||
import { appConfig, HostMode } from '../../../../appConfig/appConfig';
|
||||
import { getComponentNameFromQueryString } from '../../../shared/utils/queryStringHelper';
|
||||
import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext';
|
||||
import './deviceEvents.scss';
|
||||
import { getBackUrl } from '../../pnp/utils';
|
||||
import { AppInsightsClient } from '../../../shared/appTelemetry/appInsightsClient';
|
||||
import { TELEMETRY_USER_ACTIONS } from '../../../../app/constants/telemetry';
|
||||
import { useDeviceEventsStateContext } from '../context/deviceEventsStateContext';
|
||||
import './deviceEvents.scss';
|
||||
|
||||
export interface CommandsProps {
|
||||
startDisabled: boolean;
|
||||
|
@ -30,6 +29,7 @@ export interface CommandsProps {
|
|||
setShowSimulationPanel: (showSimulationPanel: boolean) => void;
|
||||
setShowContentTypePanel: (showDecoderPanel: boolean) => void;
|
||||
fetchData(): void;
|
||||
stopFetching(): void;
|
||||
}
|
||||
|
||||
export const Commands: React.FC<CommandsProps> = ({
|
||||
|
@ -44,12 +44,13 @@ export const Commands: React.FC<CommandsProps> = ({
|
|||
setShowPnpModeledEvents,
|
||||
setShowSimulationPanel,
|
||||
setShowContentTypePanel,
|
||||
fetchData}) => {
|
||||
fetchData,
|
||||
stopFetching}) => {
|
||||
|
||||
const {t} = useTranslation();
|
||||
const { search, pathname } = useLocation();
|
||||
const history = useHistory();
|
||||
const { pnpState, getModelDefinition } = usePnpStateContext();
|
||||
const { getModelDefinition } = usePnpStateContext();
|
||||
const componentName = getComponentNameFromQueryString(search); // if component name exist, we are in pnp context
|
||||
const [ state, api ] = useDeviceEventsStateContext();
|
||||
|
||||
|
@ -111,32 +112,18 @@ export const Commands: React.FC<CommandsProps> = ({
|
|||
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
const createStartMonitoringCommandItem = (): ICommandBarItemProps => {
|
||||
if (appConfig.hostMode !== HostMode.Browser) {
|
||||
const label = monitoringData ? t(ResourceKeys.deviceEvents.command.stop) : t(ResourceKeys.deviceEvents.command.start);
|
||||
const icon = monitoringData ? STOP : START;
|
||||
return {
|
||||
ariaLabel: label,
|
||||
disabled: startDisabled,
|
||||
iconProps: {
|
||||
iconName: icon
|
||||
},
|
||||
key: icon,
|
||||
name: label,
|
||||
onClick: onToggleStart
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
ariaLabel: t(ResourceKeys.deviceEvents.command.fetch),
|
||||
disabled: state.formMode === 'updating' || monitoringData,
|
||||
iconProps: {
|
||||
iconName: START
|
||||
},
|
||||
key: START,
|
||||
name: t(ResourceKeys.deviceEvents.command.fetch),
|
||||
onClick: onToggleStart
|
||||
};
|
||||
}
|
||||
const label = monitoringData ? t(ResourceKeys.deviceEvents.command.stop) : t(ResourceKeys.deviceEvents.command.start);
|
||||
const icon = monitoringData ? STOP : START;
|
||||
return {
|
||||
ariaLabel: label,
|
||||
disabled: startDisabled,
|
||||
iconProps: {
|
||||
iconName: icon
|
||||
},
|
||||
key: icon,
|
||||
name: label,
|
||||
onClick: onToggleStart
|
||||
};
|
||||
};
|
||||
|
||||
const createSimulationCommandItem = (): ICommandBarItemProps => {
|
||||
|
@ -194,6 +181,7 @@ export const Commands: React.FC<CommandsProps> = ({
|
|||
const onToggleStart = () => {
|
||||
if (monitoringData) {
|
||||
setMonitoringData(false);
|
||||
stopFetching();
|
||||
} else {
|
||||
fetchData();
|
||||
setMonitoringData(true);
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
/***********************************************************
|
||||
* 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 { TextField } from '@fluentui/react';
|
||||
import { ConsumerGroup } from './consumerGroup';
|
||||
|
||||
describe('ConsumerGroup', () => {
|
||||
it('matches snapshot', () => {
|
||||
expect(shallow(<ConsumerGroup monitoringData={true} consumerGroup={'$Default'} setConsumerGroup={jest.fn()}/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('changes state accordingly when consumer group value is changed', () => {
|
||||
const setConsumerGroup = jest.fn();
|
||||
const wrapper = mount(<ConsumerGroup monitoringData={false} consumerGroup={''} setConsumerGroup={setConsumerGroup}/>);
|
||||
const textField = wrapper.find(TextField).first();
|
||||
act(() => textField.props().onChange(undefined, 'testGroup'));
|
||||
expect(setConsumerGroup).toBeCalledWith('testGroup');
|
||||
});
|
||||
});
|
|
@ -22,10 +22,8 @@ describe('customEventHub', () => {
|
|||
<CustomEventHub
|
||||
monitoringData={true}
|
||||
useBuiltInEventHub={false}
|
||||
customEventHubName={undefined}
|
||||
customEventHubConnectionString={undefined}
|
||||
customEventHubConnectionString={''}
|
||||
setUseBuiltInEventHub={jest.fn()}
|
||||
setCustomEventHubName={jest.fn()}
|
||||
setCustomEventHubConnectionString={jest.fn()}
|
||||
setHasError={jest.fn()}
|
||||
/>)).toMatchSnapshot();
|
||||
|
@ -39,10 +37,8 @@ describe('customEventHub', () => {
|
|||
<CustomEventHub
|
||||
monitoringData={true}
|
||||
useBuiltInEventHub={false}
|
||||
customEventHubName={undefined}
|
||||
customEventHubConnectionString={undefined}
|
||||
customEventHubConnectionString={''}
|
||||
setUseBuiltInEventHub={mockSetUseBuiltInEventHub}
|
||||
setCustomEventHubName={mockSetCustomEventHubName}
|
||||
setCustomEventHubConnectionString={mockSetCustomEventHubConnectionString}
|
||||
setHasError={jest.fn()}
|
||||
/>);
|
||||
|
@ -52,9 +48,7 @@ describe('customEventHub', () => {
|
|||
expect(mockSetUseBuiltInEventHub).toBeCalledWith(true);
|
||||
|
||||
act(() => wrapper.find(TextField).first().props().onChange(undefined, 'connectionString'));
|
||||
act(() => wrapper.find(TextField).at(1).props().onChange(undefined, 'hubName'));
|
||||
wrapper.update();
|
||||
expect(mockSetCustomEventHubConnectionString).toBeCalledWith('connectionString');
|
||||
expect(mockSetCustomEventHubName).toBeCalledWith('hubName');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,10 +12,8 @@ import './deviceEvents.scss';
|
|||
export interface CustomEventHubProps {
|
||||
monitoringData: boolean;
|
||||
useBuiltInEventHub: boolean;
|
||||
customEventHubName: string;
|
||||
customEventHubConnectionString: string;
|
||||
setUseBuiltInEventHub: (monitoringData: boolean) => void;
|
||||
setCustomEventHubName: (customEventHubName: string) => void;
|
||||
setCustomEventHubConnectionString: (customEventHubConnectionString: string) => void;
|
||||
setHasError: (hasError: boolean) => void;
|
||||
}
|
||||
|
@ -23,10 +21,8 @@ export interface CustomEventHubProps {
|
|||
export const CustomEventHub: React.FC<CustomEventHubProps> = ({
|
||||
monitoringData,
|
||||
useBuiltInEventHub,
|
||||
customEventHubName,
|
||||
customEventHubConnectionString,
|
||||
setUseBuiltInEventHub,
|
||||
setCustomEventHubName,
|
||||
setCustomEventHubConnectionString,
|
||||
setHasError}) => {
|
||||
|
||||
|
@ -52,10 +48,6 @@ export const CustomEventHub: React.FC<CustomEventHubProps> = ({
|
|||
setCustomEventHubConnectionString(newValue);
|
||||
};
|
||||
|
||||
const customEventHubNameChange = (event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
|
||||
setCustomEventHubName(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack horizontal={true} horizontalAlign="space-between">
|
||||
<Toggle
|
||||
|
@ -82,16 +74,6 @@ export const CustomEventHub: React.FC<CustomEventHubProps> = ({
|
|||
errorMessage={error}
|
||||
required={true}
|
||||
/>
|
||||
<TextField
|
||||
className="custom-text-field"
|
||||
label={t(ResourceKeys.deviceEvents.customEventHub.name.label)}
|
||||
ariaLabel={t(ResourceKeys.deviceEvents.customEventHub.name.label)}
|
||||
underlined={true}
|
||||
value={customEventHubName}
|
||||
disabled={monitoringData}
|
||||
onChange={customEventHubNameChange}
|
||||
required={true}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
</Stack>
|
||||
|
|
|
@ -4,22 +4,11 @@
|
|||
**********************************************************/
|
||||
import 'jest';
|
||||
import * as React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { TextField, CommandBar, Shimmer } from '@fluentui/react';
|
||||
import { DeviceEvents } from './deviceEvents';
|
||||
import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants';
|
||||
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';
|
||||
import * as TransformHelper from '../../../api/dataTransforms/transformHelper';
|
||||
import * as deviceEventsStateContext from '../context/deviceEventsStateContext';
|
||||
import { getInitialDeviceEventsState } from '../state';
|
||||
|
||||
const pathname = `#/devices/detail/events/?id=device1`;
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useHistory: () => ({ push: jest.fn() }),
|
||||
|
@ -27,250 +16,27 @@ jest.mock('react-router-dom', () => ({
|
|||
}));
|
||||
|
||||
describe('deviceEvents', () => {
|
||||
describe('deviceEvents in non-pnp context', () => {
|
||||
const events = [{
|
||||
body: {
|
||||
humid: 123
|
||||
},
|
||||
enqueuedTime: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
properties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
it('matches snapshot when loading', () => {
|
||||
const pnpState: PnpStateInterface = {
|
||||
...pnpStateInitial(),
|
||||
modelDefinitionWithSource: {
|
||||
payload: undefined,
|
||||
synchronizationStatus: SynchronizationStatus.working
|
||||
}
|
||||
}];
|
||||
|
||||
beforeEach(() => {
|
||||
const realUseState = React.useState;
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP));
|
||||
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(undefined));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(TransformHelper, 'parseDateTimeString').mockImplementationOnce(parameters => {
|
||||
return '9:44:58 PM, 10/14/2019';
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const startEventsMonitoring = jest.fn();
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps(), startEventsMonitoring}]);
|
||||
expect(shallow(<DeviceEvents/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('changes state accordingly when command bar buttons are clicked', () => {
|
||||
const startEventsMonitoring = jest.fn();
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[getInitialDeviceEventsState(),
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps(), startEventsMonitoring}]);
|
||||
const wrapper = mount(<DeviceEvents/>);
|
||||
const commandBar = wrapper.find(CommandBar).first();
|
||||
// tslint:disable-next-line: no-magic-numbers
|
||||
expect(commandBar.props().items.length).toEqual(5);
|
||||
|
||||
// click the start button
|
||||
act(() => commandBar.props().items[0].onClick(null));
|
||||
wrapper.update();
|
||||
expect(startEventsMonitoring).toBeCalledWith({
|
||||
consumerGroup: DEFAULT_CONSUMER_GROUP,
|
||||
deviceId: 'device1',
|
||||
moduleId: null,
|
||||
startListeners: true,
|
||||
startTime: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('changes state accordingly when consumer group value is changed', () => {
|
||||
const wrapper = mount(<DeviceEvents/>);
|
||||
const textField = wrapper.find(TextField).first();
|
||||
act(() => textField.props().onChange(undefined, 'testGroup'));
|
||||
wrapper.update();
|
||||
expect(wrapper.find(TextField).first().props().value).toEqual('testGroup');
|
||||
});
|
||||
|
||||
it('renders events', () => {
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({ pnpState: pnpStateInitial(), dispatch: jest.fn()});
|
||||
const wrapper = mount(<DeviceEvents/>);
|
||||
const rawTelemetry = wrapper.find('article');
|
||||
expect(rawTelemetry).toHaveLength(1);
|
||||
});
|
||||
};
|
||||
jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()});
|
||||
expect(shallow(<DeviceEvents/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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});
|
||||
it('matches snapshot after loaded', () => {
|
||||
const pnpState: PnpStateInterface = {
|
||||
...pnpStateInitial(),
|
||||
modelDefinitionWithSource: {
|
||||
payload: undefined,
|
||||
synchronizationStatus: SynchronizationStatus.fetched
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const realUseState = React.useState;
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(DEFAULT_CONSUMER_GROUP));
|
||||
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(undefined));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(undefined));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(false));
|
||||
jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(true));
|
||||
jest.spyOn(TransformHelper, 'parseDateTimeString').mockImplementationOnce(parameters => {
|
||||
return '9:44:58 PM, 10/14/2019';
|
||||
});
|
||||
});
|
||||
|
||||
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(<DeviceEvents/>);
|
||||
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(<DeviceEvents/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('matches snapshot while interface definition is retrieved', () => {
|
||||
mockFetchedState();
|
||||
expect(shallow(<DeviceEvents/>)).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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
}
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = mount(<DeviceEvents/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
}
|
||||
}];
|
||||
mockFetchedState();
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = mount(<DeviceEvents/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {}
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = shallow(<DeviceEvents/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)'
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = shallow(<DeviceEvents/>);
|
||||
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
|
||||
});
|
||||
jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({pnpState, dispatch: jest.fn(), getModelDefinition: jest.fn()});
|
||||
expect(shallow(<DeviceEvents/>)).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -3,49 +3,33 @@
|
|||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Spinner, SpinnerSize, Label, Stack, MessageBarType, MessageBar } from '@fluentui/react';
|
||||
import { Stack } from '@fluentui/react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ResourceKeys } from '../../../../localization/resourceKeys';
|
||||
import { Message, MESSAGE_SYSTEM_PROPERTIES, MESSAGE_PROPERTIES, IOTHUB_MESSAGE_SOURCE_TELEMETRY } from '../../../api/models/messages';
|
||||
import { getDeviceIdFromQueryString, getComponentNameFromQueryString, getInterfaceIdFromQueryString, getModuleIdentityIdFromQueryString } from '../../../shared/utils/queryStringHelper';
|
||||
import { getDeviceIdFromQueryString, getModuleIdentityIdFromQueryString } from '../../../shared/utils/queryStringHelper';
|
||||
import { SynchronizationStatus } from '../../../api/models/synchronizationStatus';
|
||||
import { MonitorEventsParameters } from '../../../api/parameters/deviceParameters';
|
||||
import { DEFAULT_CONSUMER_GROUP } from '../../../constants/apiConstants';
|
||||
import { appConfig, HostMode } from '../../../../appConfig/appConfig';
|
||||
import { DEFAULT_CONSUMER_GROUP, WEBSOCKET_ENDPOINT } from '../../../constants/apiConstants';
|
||||
import { HeaderView } from '../../../shared/components/headerView';
|
||||
import { useDeviceEventsStateContext } from '../context/deviceEventsStateContext';
|
||||
import { DEFAULT_COMPONENT_FOR_DIGITAL_TWIN } from '../../../constants/devices';
|
||||
import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext';
|
||||
import { getDeviceTelemetry, TelemetrySchema } from '../../pnp/components/deviceEvents/dataHelper';
|
||||
import { MultiLineShimmer } from '../../../shared/components/multiLineShimmer';
|
||||
import { ErrorBoundary } from '../../shared/components/errorBoundary';
|
||||
import { SemanticUnit } from '../../../shared/units/components/semanticUnit';
|
||||
import { getSchemaType, getSchemaValidationErrors } from '../../../shared/utils/jsonSchemaAdaptor';
|
||||
import { ParsedJsonSchema } from '../../../api/models/interfaceJsonParserOutput';
|
||||
import { TelemetryContent } from '../../../api/models/modelDefinition';
|
||||
import { getLocalizedData } from '../../../api/dataTransforms/modelDefinitionTransform';
|
||||
import { DeviceSimulationPanel } from './deviceSimulationPanel';
|
||||
import { Commands } from './commands';
|
||||
import { CustomEventHub } from './customEventHub';
|
||||
import { ConsumerGroup } from './consumerGroup';
|
||||
import { StartTime } from './startTime';
|
||||
import { AppInsightsClient } from '../../../shared/appTelemetry/appInsightsClient';
|
||||
import { TELEMETRY_PAGE_NAMES } from '../../../../app/constants/telemetry';
|
||||
import './deviceEvents.scss';
|
||||
import { DeviceContentTypePanel } from './deviceContentTypePanel';
|
||||
import { Loader } from './loader';
|
||||
import { EventsContent } from './eventsContent';
|
||||
import './deviceEvents.scss';
|
||||
|
||||
const JSON_SPACES = 2;
|
||||
const LOADING_LOCK = 8000;
|
||||
|
||||
let client: WebSocket;
|
||||
export const DeviceEvents: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const deviceId = getDeviceIdFromQueryString(search);
|
||||
const moduleId = getModuleIdentityIdFromQueryString(search);
|
||||
const [ state, api ] = useDeviceEventsStateContext();
|
||||
|
||||
const events = state.message;
|
||||
const decoderPrototype = state.contentType.decoderPrototype;
|
||||
|
||||
// event hub settings
|
||||
|
@ -54,7 +38,6 @@ export const DeviceEvents: React.FC = () => {
|
|||
const [startTime, setStartTime] = React.useState<Date>();
|
||||
const [useBuiltInEventHub, setUseBuiltInEventHub] = React.useState<boolean>(true);
|
||||
const [customEventHubConnectionString, setCustomEventHubConnectionString] = React.useState<string>(undefined);
|
||||
const [customEventHubName, setCustomEventHubName] = React.useState<string>(undefined);
|
||||
const [showSystemProperties, setShowSystemProperties] = React.useState<boolean>(false);
|
||||
|
||||
// event message state
|
||||
|
@ -63,14 +46,8 @@ export const DeviceEvents: React.FC = () => {
|
|||
const [hasError, setHasError] = React.useState<boolean>(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, } = 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);
|
||||
|
||||
// simulation specific
|
||||
|
@ -83,17 +60,19 @@ export const DeviceEvents: React.FC = () => {
|
|||
() => {
|
||||
return () => {
|
||||
stopMonitoring();
|
||||
client?.close();
|
||||
};
|
||||
},
|
||||
[]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (componentName) {
|
||||
AppInsightsClient.getInstance()?.trackPageView({ name: TELEMETRY_PAGE_NAMES.PNP_TELEMETRY });
|
||||
} else {
|
||||
AppInsightsClient.getInstance()?.trackPageView({ name: TELEMETRY_PAGE_NAMES.DEVICE_TELEMETRY });
|
||||
}
|
||||
}, []); // tslint:disable-line: align
|
||||
React.useEffect(
|
||||
() => {
|
||||
client = new WebSocket(WEBSOCKET_ENDPOINT);
|
||||
client.onmessage = message => {
|
||||
api.setEvents(JSON.parse(message.data));
|
||||
};
|
||||
},
|
||||
[]);
|
||||
|
||||
React.useEffect( // tslint:disable-next-line: cyclomatic-complexity
|
||||
() => {
|
||||
|
@ -101,43 +80,14 @@ export const DeviceEvents: React.FC = () => {
|
|||
// when specifying start time, valid time need to be provided
|
||||
(specifyStartTime && (!startTime || hasError)) ||
|
||||
// when using custom event hub, both valid connection string and name need to be provided
|
||||
(!useBuiltInEventHub && (!customEventHubConnectionString || !customEventHubName || hasError))) {
|
||||
(!useBuiltInEventHub && (!customEventHubConnectionString || hasError))) {
|
||||
setStartDisabled(true);
|
||||
}
|
||||
else {
|
||||
setStartDisabled(false);
|
||||
}
|
||||
},
|
||||
[hasError, state.formMode, useBuiltInEventHub, customEventHubConnectionString, customEventHubName, specifyStartTime, startTime]);
|
||||
|
||||
React.useEffect(// tslint:disable-next-line: cyclomatic-complexity
|
||||
() => {
|
||||
if (state.formMode === 'fetched') {
|
||||
if (appConfig.hostMode !== HostMode.Browser) {
|
||||
if (monitoringData) {
|
||||
setStartTime(new Date());
|
||||
setTimeout(
|
||||
() => {
|
||||
fetchData(false)();
|
||||
},
|
||||
LOADING_LOCK);
|
||||
}
|
||||
else {
|
||||
stopMonitoring();
|
||||
}
|
||||
}
|
||||
else {
|
||||
stopMonitoring();
|
||||
}
|
||||
}
|
||||
if (state.formMode === 'upserted') {
|
||||
setMonitoringData(false);
|
||||
}
|
||||
if (monitoringData && state.formMode === 'failed') {
|
||||
stopMonitoring();
|
||||
}
|
||||
},
|
||||
[state.formMode]);
|
||||
[hasError, state.formMode, useBuiltInEventHub, customEventHubConnectionString, specifyStartTime, startTime]);
|
||||
|
||||
const renderCommands = () => {
|
||||
return (
|
||||
|
@ -153,7 +103,8 @@ export const DeviceEvents: React.FC = () => {
|
|||
setShowPnpModeledEvents={setShowPnpModeledEvents}
|
||||
setShowSimulationPanel={setShowSimulationPanel}
|
||||
setShowContentTypePanel={setShowContentTypePanel}
|
||||
fetchData={fetchData(true)}
|
||||
fetchData={fetchData}
|
||||
stopFetching={stopMonitoring}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -189,10 +140,8 @@ export const DeviceEvents: React.FC = () => {
|
|||
<CustomEventHub
|
||||
monitoringData={monitoringData}
|
||||
useBuiltInEventHub={useBuiltInEventHub}
|
||||
customEventHubName={customEventHubName}
|
||||
customEventHubConnectionString={customEventHubConnectionString}
|
||||
setUseBuiltInEventHub={setUseBuiltInEventHub}
|
||||
setCustomEventHubName={setCustomEventHubName}
|
||||
setCustomEventHubConnectionString={setCustomEventHubConnectionString}
|
||||
setHasError={setHasError}
|
||||
/>
|
||||
|
@ -204,308 +153,23 @@ export const DeviceEvents: React.FC = () => {
|
|||
api.stopEventsMonitoring();
|
||||
};
|
||||
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
const filterMessage = (message: Message) => {
|
||||
if (!message || !message.systemProperties) {
|
||||
return false;
|
||||
}
|
||||
if (message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_MESSAGE_SOURCE] &&
|
||||
message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_MESSAGE_SOURCE].toLowerCase() !== IOTHUB_MESSAGE_SOURCE_TELEMETRY) {
|
||||
// filter out telemetry sent from other sources
|
||||
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 renderRawEvents = () => {
|
||||
const filteredEvents = componentName ? events?.filter(result => filterMessage(result)) : events;
|
||||
return (
|
||||
<>
|
||||
{
|
||||
filteredEvents && filteredEvents.map((event: Message, index) => {
|
||||
const modifiedEvents = showSystemProperties ? event : {
|
||||
body: event.body,
|
||||
enqueuedTime: event.enqueuedTime,
|
||||
properties: event.properties
|
||||
};
|
||||
return (
|
||||
<article key={index} className="device-events-content">
|
||||
{<h5>{modifiedEvents.enqueuedTime}:</h5>}
|
||||
<pre>{JSON.stringify(modifiedEvents, undefined, JSON_SPACES)}</pre>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
//#region pnp specific render
|
||||
const renderPnpModeledEvents = () => {
|
||||
const filteredEvents = componentName ? events.filter(result => filterMessage(result)) : events;
|
||||
return (
|
||||
<>
|
||||
{
|
||||
filteredEvents && filteredEvents.length > 0 &&
|
||||
<>
|
||||
<div className="pnp-modeled-list">
|
||||
<div className="list-header list-header-uncollapsible flex-grid-row">
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.timestamp)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.displayName)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.schema)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.unit)}</span>
|
||||
<span className="col-sm4">{t(ResourceKeys.deviceEvents.columns.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<section role="feed">
|
||||
{
|
||||
filteredEvents.map((event: Message, index) => {
|
||||
return !event.systemProperties ? renderEventsWithNoSystemProperties(event, index) :
|
||||
event.systemProperties[TELEMETRY_SCHEMA_PROP] ?
|
||||
renderEventsWithSchemaProperty(event, index) :
|
||||
renderEventsWithNoSchemaProperty(event, index);
|
||||
})
|
||||
}
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventsWithNoSystemProperties = (event: Message, index: number) => {
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName()}
|
||||
{renderEventSchema()}
|
||||
{renderEventUnit()}
|
||||
{renderMessageBodyWithNoSchema(event.body)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventsWithSchemaProperty = (event: Message, index: number) => {
|
||||
const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(event.systemProperties[TELEMETRY_SCHEMA_PROP]);
|
||||
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName(telemetryModelDefinition)}
|
||||
{renderEventSchema(telemetryModelDefinition)}
|
||||
{renderEventUnit(telemetryModelDefinition)}
|
||||
{renderMessageBodyWithSchema(event.body, parsedSchema, event.systemProperties[TELEMETRY_SCHEMA_PROP])}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<article className="list-item event-list-item" role="article" key={key + index}>
|
||||
<section className={`flex-grid-row item-summary ${isNotItemLast && 'item-summary-partial'}`}>
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(keyIndex === 0 ? event.enqueuedTime : null)}
|
||||
{renderEventName(telemetryModelDefinition)}
|
||||
{renderEventSchema(telemetryModelDefinition)}
|
||||
{renderEventUnit(telemetryModelDefinition)}
|
||||
{renderMessageBodyWithSchema(partialEventBody, parsedSchema, key)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName()}
|
||||
{renderEventSchema()}
|
||||
{renderEventUnit()}
|
||||
{renderMessageBodyWithSchema(event.body, null, null)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTimestamp = (enqueuedTime: string) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.timestamp)}>
|
||||
{enqueuedTime}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventName = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
const displayName = telemetryModelDefinition ? getLocalizedData(telemetryModelDefinition.displayName) : '';
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.displayName)}>
|
||||
{telemetryModelDefinition ?
|
||||
`${telemetryModelDefinition.name} (${displayName ? displayName : '--'})` : '--'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventSchema = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.schema)}>
|
||||
{telemetryModelDefinition ? getSchemaType(telemetryModelDefinition.schema) : '--'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventUnit = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.unit)}>
|
||||
<SemanticUnit unitHost={telemetryModelDefinition} />
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<span aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
<Label className="value-validation-error">
|
||||
{labelContent}
|
||||
</Label>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<span aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
<Label className="value-validation-error">
|
||||
{labelContent}
|
||||
</Label>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
{errors.length !== 0 &&
|
||||
<section className="value-validation-error" aria-label={t(ResourceKeys.deviceEvents.columns.validation.value.label)}>
|
||||
<span>{t(ResourceKeys.deviceEvents.columns.validation.value.label)}</span>
|
||||
<ul>
|
||||
{errors.map((element, index) =>
|
||||
<li key={index}>{element.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageBodyWithNoSchema = (eventBody: any) => { // tslint:disable-line:no-any
|
||||
return (
|
||||
<div className="column-value-text col-sm4">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 fetchData = () => {
|
||||
client.onopen = () => { // intentionally blank
|
||||
};
|
||||
};
|
||||
//#endregion
|
||||
|
||||
const renderLoader = (): JSX.Element => {
|
||||
return (
|
||||
<>
|
||||
{monitoringData &&
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.info}
|
||||
>
|
||||
<Stack horizontal={true} tokens={{ childrenGap: 10 }}>
|
||||
<div>{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}</div>
|
||||
{<Spinner size={SpinnerSize.small} />}
|
||||
</Stack>
|
||||
</MessageBar>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const fetchData = (startListeners: boolean) => () => {
|
||||
let parameters: MonitorEventsParameters = {
|
||||
consumerGroup,
|
||||
decoderPrototype,
|
||||
deviceId,
|
||||
moduleId,
|
||||
startListeners,
|
||||
startTime
|
||||
};
|
||||
|
||||
if (!useBuiltInEventHub) {
|
||||
parameters = {
|
||||
...parameters,
|
||||
customEventHubConnectionString,
|
||||
customEventHubName
|
||||
customEventHubConnectionString
|
||||
};
|
||||
}
|
||||
|
||||
api.startEventsMonitoring(parameters);
|
||||
};
|
||||
|
||||
|
@ -540,10 +204,8 @@ export const DeviceEvents: React.FC = () => {
|
|||
onToggleContentTypePanel={onToggleContentTypePanel}
|
||||
/>
|
||||
<div className="device-events-container">
|
||||
{renderLoader()}
|
||||
<div className={componentName ? 'pnp-telemetry' : ''}>
|
||||
{showPnpModeledEvents ? renderPnpModeledEvents() : renderRawEvents()}
|
||||
</div>
|
||||
<Loader monitoringData={monitoringData}/>
|
||||
<EventsContent showPnpModeledEvents={showPnpModeledEvents} showSystemProperties={showSystemProperties}/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import 'jest';
|
||||
import * as React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { REPOSITORY_LOCATION_TYPE } from '../../../constants/repositoryLocationTypes';
|
||||
import { ResourceKeys } from '../../../../localization/resourceKeys';
|
||||
import { SynchronizationStatus } from '../../../api/models/synchronizationStatus';
|
||||
import * as pnpStateContext from '../../../shared/contexts/pnpStateContext';
|
||||
import { ErrorBoundary } from '../../shared/components/errorBoundary';
|
||||
import { pnpStateInitial, PnpStateInterface } from '../../pnp/state';
|
||||
import { testModelDefinition } from '../../pnp/components/deviceEvents/testData';
|
||||
import * as deviceEventsStateContext from '../context/deviceEventsStateContext';
|
||||
import { getInitialDeviceEventsState } from '../state';
|
||||
import { EventsContent } from './eventsContent';
|
||||
|
||||
const pathname = `#/devices/detail/events/?id=device1`;
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useHistory: () => ({ push: jest.fn() }),
|
||||
useLocation: () => ({ search: `?deviceId=device1`, pathname, push: jest.fn() })
|
||||
}));
|
||||
|
||||
describe('EventsContent', () => {
|
||||
describe('EventsContent in non-pnp context', () => {
|
||||
const events = [{
|
||||
body: {
|
||||
humid: 123
|
||||
},
|
||||
enqueuedTime: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
properties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
}
|
||||
}];
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const startEventsMonitoring = jest.fn();
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps(), startEventsMonitoring}]);
|
||||
expect(shallow(<EventsContent showPnpModeledEvents={false} showSystemProperties={false}/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders events', () => {
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
jest.spyOn(pnpStateContext, 'usePnpStateContext').mockReturnValue({ pnpState: pnpStateInitial(), dispatch: jest.fn()});
|
||||
const wrapper = mount(<EventsContent showPnpModeledEvents={false} showSystemProperties={false}/>);
|
||||
const rawTelemetry = wrapper.find('article');
|
||||
expect(rawTelemetry).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventsContent 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});
|
||||
};
|
||||
|
||||
it('matches snapshot while interface definition is retrieved', () => {
|
||||
mockFetchedState();
|
||||
expect(shallow(<EventsContent showPnpModeledEvents={true} showSystemProperties={false}/>)).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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
}
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = mount(<EventsContent showPnpModeledEvents={true} showSystemProperties={false}/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {
|
||||
'iothub-message-schema': 'humid'
|
||||
}
|
||||
}];
|
||||
mockFetchedState();
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = mount(<EventsContent showPnpModeledEvents={true} showSystemProperties={false}/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)',
|
||||
systemProperties: {}
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = shallow(<EventsContent showPnpModeledEvents={true} showSystemProperties={false}/>);
|
||||
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: 'Wed Feb 17 2021 16:06:00 GMT-0800 (Pacific Standard Time)'
|
||||
}];
|
||||
jest.spyOn(deviceEventsStateContext, 'useDeviceEventsStateContext').mockReturnValue(
|
||||
[{...getInitialDeviceEventsState(), message: events, formMode: 'fetched'},
|
||||
{...deviceEventsStateContext.getInitialDeviceEventsOps()}]);
|
||||
mockFetchedState();
|
||||
|
||||
const wrapper = shallow(<EventsContent showPnpModeledEvents={true} showSystemProperties={false}/>);
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,326 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Label } from '@fluentui/react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ResourceKeys } from '../../../../localization/resourceKeys';
|
||||
import { Message, MESSAGE_SYSTEM_PROPERTIES, MESSAGE_PROPERTIES, IOTHUB_MESSAGE_SOURCE_TELEMETRY } from '../../../api/models/messages';
|
||||
import { getComponentNameFromQueryString, getInterfaceIdFromQueryString } from '../../../shared/utils/queryStringHelper';
|
||||
import { useDeviceEventsStateContext } from '../context/deviceEventsStateContext';
|
||||
import { DEFAULT_COMPONENT_FOR_DIGITAL_TWIN } from '../../../constants/devices';
|
||||
import { usePnpStateContext } from '../../../shared/contexts/pnpStateContext';
|
||||
import { getDeviceTelemetry, TelemetrySchema } from '../../pnp/components/deviceEvents/dataHelper';
|
||||
import { ErrorBoundary } from '../../shared/components/errorBoundary';
|
||||
import { SemanticUnit } from '../../../shared/units/components/semanticUnit';
|
||||
import { getSchemaType, getSchemaValidationErrors } from '../../../shared/utils/jsonSchemaAdaptor';
|
||||
import { ParsedJsonSchema } from '../../../api/models/interfaceJsonParserOutput';
|
||||
import { TelemetryContent } from '../../../api/models/modelDefinition';
|
||||
import { getLocalizedData } from '../../../api/dataTransforms/modelDefinitionTransform';
|
||||
import { AppInsightsClient } from '../../../shared/appTelemetry/appInsightsClient';
|
||||
import { TELEMETRY_PAGE_NAMES } from '../../../../app/constants/telemetry';
|
||||
|
||||
const JSON_SPACES = 2;
|
||||
interface EventsContentProps {
|
||||
showSystemProperties: boolean;
|
||||
showPnpModeledEvents: boolean;
|
||||
}
|
||||
export const EventsContent: React.FC<EventsContentProps> = ({showSystemProperties, showPnpModeledEvents}) => {
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (componentName) {
|
||||
AppInsightsClient.getInstance()?.trackPageView({ name: TELEMETRY_PAGE_NAMES.PNP_TELEMETRY });
|
||||
} else {
|
||||
AppInsightsClient.getInstance()?.trackPageView({ name: TELEMETRY_PAGE_NAMES.DEVICE_TELEMETRY });
|
||||
}
|
||||
},
|
||||
[]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const [ {message: events} ] = useDeviceEventsStateContext();
|
||||
const { pnpState, } = usePnpStateContext();
|
||||
|
||||
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 modelDefinitionWithSource = pnpState.modelDefinitionWithSource.payload;
|
||||
const modelDefinition = modelDefinitionWithSource && modelDefinitionWithSource.modelDefinition;
|
||||
const telemetrySchema = React.useMemo(() => getDeviceTelemetry(modelDefinition), [modelDefinition]);
|
||||
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
const filterMessage = (message: Message) => {
|
||||
if (!message || !message.systemProperties) {
|
||||
return false;
|
||||
}
|
||||
if (message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_MESSAGE_SOURCE] &&
|
||||
message.systemProperties[MESSAGE_SYSTEM_PROPERTIES.IOTHUB_MESSAGE_SOURCE].toLowerCase() !== IOTHUB_MESSAGE_SOURCE_TELEMETRY) {
|
||||
// filter out telemetry sent from other sources
|
||||
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 renderRawEvents = () => {
|
||||
const filteredEvents = componentName ? events?.filter(result => filterMessage(result)) : events;
|
||||
return (
|
||||
<>
|
||||
{
|
||||
filteredEvents && filteredEvents.map((event: Message, index) => {
|
||||
const modifiedEvents = showSystemProperties ? event : {
|
||||
body: event.body,
|
||||
enqueuedTime: event.enqueuedTime,
|
||||
properties: event.properties
|
||||
};
|
||||
return (
|
||||
<article key={index} className="device-events-content">
|
||||
{<h5>{modifiedEvents.enqueuedTime}:</h5>}
|
||||
<pre>{JSON.stringify(modifiedEvents, undefined, JSON_SPACES)}</pre>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
//#region pnp specific render
|
||||
const renderPnpModeledEvents = () => {
|
||||
const filteredEvents = componentName ? events.filter(result => filterMessage(result)) : events;
|
||||
return (
|
||||
<>
|
||||
{
|
||||
filteredEvents && filteredEvents.length > 0 &&
|
||||
<>
|
||||
<div className="pnp-modeled-list">
|
||||
<div className="list-header list-header-uncollapsible flex-grid-row">
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.timestamp)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.displayName)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.schema)}</span>
|
||||
<span className="col-sm2">{t(ResourceKeys.deviceEvents.columns.unit)}</span>
|
||||
<span className="col-sm4">{t(ResourceKeys.deviceEvents.columns.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<section role="feed">
|
||||
{
|
||||
filteredEvents.map((event: Message, index) => {
|
||||
return !event.systemProperties ? renderEventsWithNoSystemProperties(event, index) :
|
||||
event.systemProperties[TELEMETRY_SCHEMA_PROP] ?
|
||||
renderEventsWithSchemaProperty(event, index) :
|
||||
renderEventsWithNoSchemaProperty(event, index);
|
||||
})
|
||||
}
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventsWithNoSystemProperties = (event: Message, index: number) => {
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName()}
|
||||
{renderEventSchema()}
|
||||
{renderEventUnit()}
|
||||
{renderMessageBodyWithNoSchema(event.body)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventsWithSchemaProperty = (event: Message, index: number) => {
|
||||
const { telemetryModelDefinition, parsedSchema } = getModelDefinitionAndSchema(event.systemProperties[TELEMETRY_SCHEMA_PROP]);
|
||||
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName(telemetryModelDefinition)}
|
||||
{renderEventSchema(telemetryModelDefinition)}
|
||||
{renderEventUnit(telemetryModelDefinition)}
|
||||
{renderMessageBodyWithSchema(event.body, parsedSchema, event.systemProperties[TELEMETRY_SCHEMA_PROP])}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<article className="list-item event-list-item" role="article" key={key + index}>
|
||||
<section className={`flex-grid-row item-summary ${isNotItemLast && 'item-summary-partial'}`}>
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(keyIndex === 0 ? event.enqueuedTime : null)}
|
||||
{renderEventName(telemetryModelDefinition)}
|
||||
{renderEventSchema(telemetryModelDefinition)}
|
||||
{renderEventUnit(telemetryModelDefinition)}
|
||||
{renderMessageBodyWithSchema(partialEventBody, parsedSchema, key)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<article className="list-item event-list-item" role="article" key={index}>
|
||||
<section className="flex-grid-row item-summary">
|
||||
<ErrorBoundary error={t(ResourceKeys.errorBoundary.text)}>
|
||||
{renderTimestamp(event.enqueuedTime)}
|
||||
{renderEventName()}
|
||||
{renderEventSchema()}
|
||||
{renderEventUnit()}
|
||||
{renderMessageBodyWithSchema(event.body, null, null)}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTimestamp = (enqueuedTime: string) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.timestamp)}>
|
||||
{enqueuedTime}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventName = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
const displayName = telemetryModelDefinition ? getLocalizedData(telemetryModelDefinition.displayName) : '';
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.displayName)}>
|
||||
{telemetryModelDefinition ?
|
||||
`${telemetryModelDefinition.name} (${displayName ? displayName : '--'})` : '--'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventSchema = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.schema)}>
|
||||
{telemetryModelDefinition ? getSchemaType(telemetryModelDefinition.schema) : '--'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEventUnit = (telemetryModelDefinition?: TelemetryContent) => {
|
||||
return (
|
||||
<div className="col-sm2">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.unit)}>
|
||||
<SemanticUnit unitHost={telemetryModelDefinition} />
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<span aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
<Label className="value-validation-error">
|
||||
{labelContent}
|
||||
</Label>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<span aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
<Label className="value-validation-error">
|
||||
{labelContent}
|
||||
</Label>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="column-value-text col-sm4">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
{errors.length !== 0 &&
|
||||
<section className="value-validation-error" aria-label={t(ResourceKeys.deviceEvents.columns.validation.value.label)}>
|
||||
<span>{t(ResourceKeys.deviceEvents.columns.validation.value.label)}</span>
|
||||
<ul>
|
||||
{errors.map((element, index) =>
|
||||
<li key={index}>{element.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMessageBodyWithNoSchema = (eventBody: any) => { // tslint:disable-line:no-any
|
||||
return (
|
||||
<div className="column-value-text col-sm4">
|
||||
<Label aria-label={t(ResourceKeys.deviceEvents.columns.value)}>
|
||||
{JSON.stringify(eventBody, undefined, JSON_SPACES)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
//#endregion
|
||||
|
||||
return (
|
||||
<div className={componentName ? 'pnp-telemetry' : ''}>
|
||||
{showPnpModeledEvents ? renderPnpModeledEvents() : renderRawEvents()}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import 'jest';
|
||||
import * as React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { Loader } from './loader';
|
||||
|
||||
describe('Loader', () => {
|
||||
it('matches snapshot when loading', () => {
|
||||
expect(shallow(<Loader monitoringData={true}/>)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('matches snapshot when not loading', () => {
|
||||
expect(shallow(<Loader monitoringData={false}/>)).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Spinner, SpinnerSize, Stack, MessageBarType, MessageBar } from '@fluentui/react';
|
||||
import { ResourceKeys } from '../../../../localization/resourceKeys';
|
||||
|
||||
export const Loader: React.FC<{monitoringData: boolean}> = ({monitoringData}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{monitoringData &&
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.info}
|
||||
>
|
||||
<Stack horizontal={true} tokens={{ childrenGap: 10 }}>
|
||||
<div>{t(ResourceKeys.deviceEvents.infiniteScroll.loading)}</div>
|
||||
{<Spinner size={SpinnerSize.small} />}
|
||||
</Stack>
|
||||
</MessageBar>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -15,6 +15,7 @@ exports[`DeviceEventsStateContextProvider matches snapshot 1`] = `
|
|||
"clearEventsMonitoring": [Function],
|
||||
"setDecoderInfo": [Function],
|
||||
"setDefaultDecodeInfo": [Function],
|
||||
"setEvents": [Function],
|
||||
"startEventsMonitoring": [Function],
|
||||
"stopEventsMonitoring": [Function],
|
||||
},
|
||||
|
|
|
@ -10,6 +10,7 @@ export const getInitialDeviceEventsOps = (): DeviceEventsInterface => ({
|
|||
clearEventsMonitoring: () => undefined,
|
||||
setDecoderInfo: () => undefined,
|
||||
setDefaultDecodeInfo: () => undefined,
|
||||
setEvents: () => undefined,
|
||||
startEventsMonitoring: () => undefined,
|
||||
stopEventsMonitoring: () => undefined
|
||||
});
|
||||
|
|
|
@ -6,15 +6,17 @@ import * as React from 'react';
|
|||
import { getInitialDeviceEventsState } from '../state';
|
||||
import { deviceEventsReducer } from '../reducers';
|
||||
import { DeviceEventsStateContext } from './deviceEventsStateContext';
|
||||
import { startEventsMonitoringAction, stopEventsMonitoringAction, clearMonitoringEventsAction, setDecoderInfoAction, setDefaultDecodeInfoAction } from '../actions';
|
||||
import { startEventsMonitoringAction, stopEventsMonitoringAction, clearMonitoringEventsAction, setDecoderInfoAction, setDefaultDecodeInfoAction, setEventsMessagesAction } from '../actions';
|
||||
import { EventMonitoringSaga } from '../saga';
|
||||
import { useAsyncSagaReducer } from '../../../shared/hooks/useAsyncSagaReducer';
|
||||
import { MonitorEventsParameters, SetDecoderInfoParameters } from '../../../api/parameters/deviceParameters';
|
||||
import { Message } from '../../../api/models/messages';
|
||||
|
||||
export interface DeviceEventsInterface {
|
||||
clearEventsMonitoring(): void;
|
||||
setDecoderInfo(params: SetDecoderInfoParameters): void;
|
||||
setDefaultDecodeInfo(): void;
|
||||
setEvents(messages: Message[]): void;
|
||||
startEventsMonitoring(params: MonitorEventsParameters): void;
|
||||
stopEventsMonitoring(): void;
|
||||
}
|
||||
|
@ -26,6 +28,7 @@ export const DeviceEventsStateContextProvider: React.FC = props => {
|
|||
clearEventsMonitoring: () => dispatch(clearMonitoringEventsAction()),
|
||||
setDecoderInfo: (params: SetDecoderInfoParameters) => dispatch(setDecoderInfoAction.started(params)),
|
||||
setDefaultDecodeInfo: () => dispatch(setDefaultDecodeInfoAction()),
|
||||
setEvents: (messages: Message[]) => dispatch(setEventsMessagesAction.started(messages)),
|
||||
startEventsMonitoring: (params: MonitorEventsParameters) => dispatch(startEventsMonitoringAction.started(params)),
|
||||
stopEventsMonitoring: () => dispatch(stopEventsMonitoringAction.started())
|
||||
};
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import 'jest';
|
||||
import { SET_DECODE_INFO, SET_DEFAULT_DECODE_INFO, START_EVENTS_MONITORING, STOP_EVENTS_MONITORING } from '../../constants/actionTypes';
|
||||
import { setDecoderInfoAction, setDefaultDecodeInfoAction, startEventsMonitoringAction, stopEventsMonitoringAction } from './actions';
|
||||
import { SET_DECODE_INFO, SET_DEFAULT_DECODE_INFO, SET_EVENTS_MESSAGES, START_EVENTS_MONITORING, STOP_EVENTS_MONITORING } from '../../constants/actionTypes';
|
||||
import { setDecoderInfoAction, setDefaultDecodeInfoAction, setEventsMessagesAction, startEventsMonitoringAction, stopEventsMonitoringAction } from './actions';
|
||||
import { deviceEventsReducer } from './reducers';
|
||||
import { getInitialDeviceEventsState } from './state';
|
||||
import { DEFAULT_CONSUMER_GROUP } from './../../constants/apiConstants';
|
||||
|
@ -12,7 +12,7 @@ import { Type } from 'protobufjs';
|
|||
|
||||
describe('deviceEventsReducer', () => {
|
||||
const deviceId = 'testDeviceId';
|
||||
const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, moduleId:undefined, startTime: new Date()};
|
||||
const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, moduleId:'', startTime: new Date()};
|
||||
const events = [{
|
||||
body: {
|
||||
humid: '123' // intentionally set a value which type is double
|
||||
|
@ -23,14 +23,14 @@ describe('deviceEventsReducer', () => {
|
|||
}
|
||||
}];
|
||||
const decoderParams = {decoderFile: new File([], ''), decoderPrototype: 'decoderPrototype', decodeType: 'Protobuf'};
|
||||
|
||||
it (`handles ${START_EVENTS_MONITORING}/ACTION_START action`, () => {
|
||||
const action = startEventsMonitoringAction.started(params);
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('working');
|
||||
});
|
||||
|
||||
it (`handles ${START_EVENTS_MONITORING}/ACTION_DONE action`, () => {
|
||||
const action = startEventsMonitoringAction.done({params, result: events});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual(events);
|
||||
const action = startEventsMonitoringAction.done({params});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('fetched');
|
||||
});
|
||||
|
||||
|
@ -42,19 +42,16 @@ describe('deviceEventsReducer', () => {
|
|||
it (`handles ${STOP_EVENTS_MONITORING}/ACTION_START action`, () => {
|
||||
const action = stopEventsMonitoringAction.started();
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('updating');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual([]);
|
||||
});
|
||||
|
||||
it (`handles ${STOP_EVENTS_MONITORING}/ACTION_DONE action`, () => {
|
||||
const action = stopEventsMonitoringAction.done({});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('upserted');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual([]);
|
||||
});
|
||||
|
||||
it (`handles ${STOP_EVENTS_MONITORING}/ACTION_FAILED action`, () => {
|
||||
const action = stopEventsMonitoringAction.failed({error: -1});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('failed');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual([]);
|
||||
});
|
||||
|
||||
it (`handles ${SET_DECODE_INFO}/ACTION_START action`, () => {
|
||||
|
@ -76,4 +73,22 @@ describe('deviceEventsReducer', () => {
|
|||
const action = setDefaultDecodeInfoAction();
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).contentType.decodeType).toEqual('JSON');
|
||||
});
|
||||
|
||||
it (`handles ${SET_EVENTS_MESSAGES}/ACTION_START action`, () => {
|
||||
const action = setEventsMessagesAction.started(events);
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('working');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual([]);
|
||||
});
|
||||
|
||||
it (`handles ${SET_EVENTS_MESSAGES}/ACTION_DONE action`, () => {
|
||||
const action = setEventsMessagesAction.done({params: events, result: events});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('fetched');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual(events);
|
||||
});
|
||||
|
||||
it (`handles ${SET_EVENTS_MESSAGES}/ACTION_FAILED action`, () => {
|
||||
const action = setEventsMessagesAction.failed({error: -1, params: events});
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).formMode).toEqual('failed');
|
||||
expect(deviceEventsReducer(getInitialDeviceEventsState(), action).message).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,8 @@ import {
|
|||
stopEventsMonitoringAction,
|
||||
clearMonitoringEventsAction,
|
||||
setDecoderInfoAction,
|
||||
setDefaultDecodeInfoAction
|
||||
setDefaultDecodeInfoAction,
|
||||
setEventsMessagesAction
|
||||
} from './actions';
|
||||
import { MonitorEventsParameters, SetDecoderInfoParameters } from '../../api/parameters/deviceParameters';
|
||||
import { Message } from '../../api/models/messages';
|
||||
|
@ -21,17 +22,10 @@ export const deviceEventsReducer = reducerWithInitialState<DeviceEventsStateInte
|
|||
formMode: 'working'
|
||||
};
|
||||
})
|
||||
.case(startEventsMonitoringAction.done, (state: DeviceEventsStateInterface, payload: {params: MonitorEventsParameters, result: Message[]}) => {
|
||||
const messages = payload.result ? payload.result.reverse().map((message: Message) => message) : [];
|
||||
let filteredMessages = messages;
|
||||
if (state.message.length > 0 && messages.length > 0) {
|
||||
// filter overlaped messages returned from event hub
|
||||
filteredMessages = messages.filter(message => message.enqueuedTime > state.message[0].enqueuedTime);
|
||||
}
|
||||
.case(startEventsMonitoringAction.done, (state: DeviceEventsStateInterface, payload: {params: MonitorEventsParameters}) => {
|
||||
return {
|
||||
...state,
|
||||
formMode: 'fetched',
|
||||
message: [...filteredMessages, ...state.message]
|
||||
};
|
||||
})
|
||||
.case(startEventsMonitoringAction.failed, (state: DeviceEventsStateInterface) => {
|
||||
|
@ -94,4 +88,28 @@ export const deviceEventsReducer = reducerWithInitialState<DeviceEventsStateInte
|
|||
decodeType: 'JSON'
|
||||
}
|
||||
};
|
||||
})
|
||||
.case(setEventsMessagesAction.started, (state: DeviceEventsStateInterface) => {
|
||||
return {
|
||||
...state,
|
||||
formMode: 'working'
|
||||
};
|
||||
})
|
||||
.case(setEventsMessagesAction.done, (state: DeviceEventsStateInterface, payload: { params: Message[], result: Message[]}) => {
|
||||
let messages = payload.result ? payload.result.reverse().map((message: Message) => message) : [];
|
||||
if (state.message.length > 0 && messages.length > 0) {
|
||||
// filter overlapped messages returned from event hub
|
||||
messages = messages.filter(message => message.enqueuedTime > state.message[0].enqueuedTime);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
formMode: 'fetched',
|
||||
message: [...messages, ...state.message]
|
||||
};
|
||||
})
|
||||
.case(setEventsMessagesAction.failed, (state: DeviceEventsStateInterface) => {
|
||||
return {
|
||||
...state,
|
||||
formMode: 'failed'
|
||||
};
|
||||
});
|
||||
|
|
|
@ -19,11 +19,11 @@ import { Type } from 'protobufjs';
|
|||
describe('deviceMonitoringSaga', () => {
|
||||
let startEventsMonitoringSagaGenerator;
|
||||
|
||||
const mockMonitorEventsFn = jest.spyOn(DevicesService, 'monitorEvents').mockImplementationOnce(parameters => {
|
||||
const mockMonitorEventsFn = jest.spyOn(DevicesService, 'monitorEvents').mockImplementationOnce(() => {
|
||||
return null;
|
||||
});
|
||||
const deviceId = 'test_id';
|
||||
const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, startTime: new Date()};
|
||||
const params = {consumerGroup: DEFAULT_CONSUMER_GROUP, deviceId, moduleId: ''};
|
||||
|
||||
beforeEach(() => {
|
||||
startEventsMonitoringSagaGenerator = cloneableGenerator(startEventsMonitoringSagaWorker)(startEventsMonitoringAction.started(params));
|
||||
|
@ -40,8 +40,7 @@ describe('deviceMonitoringSaga', () => {
|
|||
expect(startEventsMonitoringSagaGenerator.next([])).toEqual({
|
||||
done: false,
|
||||
value: put(startEventsMonitoringAction.done({
|
||||
params,
|
||||
result: []
|
||||
params
|
||||
}))
|
||||
});
|
||||
|
||||
|
@ -83,7 +82,7 @@ describe('deviceMonitoringSaga', () => {
|
|||
describe('setDecoderInfoSaga', () => {
|
||||
let setDecoderInfoSagaGenerator;
|
||||
const params = {decoderFile: new File([], ''), decoderPrototype: 'decoderPrototype', decodeType: 'Protobuf'};
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
setDecoderInfoSagaGenerator = cloneableGenerator(setDecoderInfoSagaWorker)(setDecoderInfoAction.started(params));
|
||||
});
|
||||
|
|
|
@ -2,22 +2,33 @@
|
|||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import { call, put, all, takeEvery, takeLatest } from 'redux-saga/effects';
|
||||
import { call, put, all, takeEvery, takeLatest, select } from 'redux-saga/effects';
|
||||
import { SagaIterator } from 'redux-saga';
|
||||
import { Action } from 'typescript-fsa';
|
||||
import { Type } from 'protobufjs';
|
||||
import { monitorEvents, stopMonitoringEvents } from '../../api/services/devicesService';
|
||||
import { monitorEvents, parseEvents, stopMonitoringEvents } from '../../api/services/devicesService';
|
||||
import { NotificationType } from '../../api/models/notification';
|
||||
import { ResourceKeys } from '../../../localization/resourceKeys';
|
||||
import { setDecoderInfoAction, startEventsMonitoringAction, stopEventsMonitoringAction } from './actions';
|
||||
import { setDecoderInfoAction, setEventsMessagesAction, startEventsMonitoringAction, stopEventsMonitoringAction } from './actions';
|
||||
import { raiseNotificationToast } from '../../notifications/components/notificationToast';
|
||||
import { MonitorEventsParameters, SetDecoderInfoParameters } from '../../api/parameters/deviceParameters';
|
||||
import { setDecoderInfo } from './utils';
|
||||
import { Message } from '../../api/models/messages';
|
||||
|
||||
export function* setEventsSagaWorker(action: Action<Message[]>): SagaIterator {
|
||||
try {
|
||||
const state = yield select();
|
||||
const result = yield call(parseEvents, {messages: action.payload, decoderPrototype: state.contentType.decoderPrototype});
|
||||
yield put(setEventsMessagesAction.done({params: action.payload, result}));
|
||||
} catch (error) {
|
||||
yield put(setEventsMessagesAction.failed({params: action.payload, error}));
|
||||
}
|
||||
}
|
||||
|
||||
export function* startEventsMonitoringSagaWorker(action: Action<MonitorEventsParameters>): SagaIterator {
|
||||
try {
|
||||
const messages = yield call(monitorEvents, action.payload);
|
||||
yield put(startEventsMonitoringAction.done({params: action.payload, result: messages}));
|
||||
yield call(monitorEvents, action.payload);
|
||||
yield put(startEventsMonitoringAction.done({params: action.payload}));
|
||||
} catch (error) {
|
||||
yield call(raiseNotificationToast, {
|
||||
text: {
|
||||
|
@ -72,6 +83,7 @@ export function* EventMonitoringSaga() {
|
|||
yield all([
|
||||
takeEvery(startEventsMonitoringAction.started, startEventsMonitoringSagaWorker),
|
||||
takeLatest(stopEventsMonitoringAction.started, stopEventsMonitoringSagaWorker),
|
||||
takeLatest(setDecoderInfoAction.started, setDecoderInfoSagaWorker)
|
||||
takeLatest(setDecoderInfoAction.started, setDecoderInfoSagaWorker),
|
||||
takeLatest(setEventsMessagesAction.started, setEventsSagaWorker)
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -578,9 +578,6 @@
|
|||
"label": "Custom event hub connection string",
|
||||
"placeHolder": "Endpoint=sb://<FQDN>/;SharedAccessKeyName=<KeyName>;SharedAccessKey=<KeyValue>",
|
||||
"error": "Event hub connection string format should look like 'Endpoint=sb://<FQDN>/;SharedAccessKeyName=<KeyName>;SharedAccessKey=<KeyValue>'"
|
||||
},
|
||||
"name": {
|
||||
"label": "Custom event hub name"
|
||||
}
|
||||
},
|
||||
"headerText": "Telemetry",
|
||||
|
|
|
@ -336,9 +336,6 @@ export class ResourceKeys {
|
|||
label : "deviceEvents.customEventHub.connectionString.label",
|
||||
placeHolder : "deviceEvents.customEventHub.connectionString.placeHolder",
|
||||
},
|
||||
name : {
|
||||
label : "deviceEvents.customEventHub.name.label",
|
||||
},
|
||||
},
|
||||
customizeContentType : {
|
||||
contentTypeOption : {
|
||||
|
|
|
@ -28,16 +28,6 @@ describe('serverBase', () => {
|
|||
});
|
||||
});
|
||||
|
||||
context('handlhandleCloudToDevicePostRequesteDataPlanePostRequest', () => {
|
||||
it('returns 400 if body is not provided', async () => {
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
await ServerBase.handleCloudToDevicePostRequest(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400); // tslint:disable-line:no-magic-numbers
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEventHubMonitorPostRequest', () => {
|
||||
it('returns 400 if body is not provided', async () => {
|
||||
const req = mockRequest();
|
||||
|
@ -46,16 +36,6 @@ describe('serverBase', () => {
|
|||
await ServerBase.handleEventHubMonitorPostRequest(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400); // tslint:disable-line:no-magic-numbers
|
||||
});
|
||||
|
||||
it('calls eventHubProvider when body is provided', async () => {
|
||||
const req = mockRequest({params: {}});
|
||||
const res = mockResponse();
|
||||
const promise = {then: jest.fn()} as any; // tslint:disable-line:no-any
|
||||
jest.spyOn(ServerBase, 'eventHubProvider').mockReturnValue(promise);
|
||||
|
||||
await ServerBase.handleEventHubMonitorPostRequest(req, res);
|
||||
expect(ServerBase.eventHubProvider).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
context('handleModelRepoPostRequest', () => {
|
||||
|
@ -73,88 +53,4 @@ describe('serverBase', () => {
|
|||
expect(res.status).toHaveBeenCalledWith(500); // tslint:disable-line:no-magic-numbers
|
||||
});
|
||||
});
|
||||
|
||||
context('addPropertiesToCloudToDeviceMessage', () => {
|
||||
it('add system properties to message', async () => {
|
||||
const message: any = {properties: new Map()}; // tslint:disable-line:no-any
|
||||
const properties = [
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'ack',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'contentType',
|
||||
value: 'json',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'correlationId',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'contentEncoding',
|
||||
value: '3',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'expiryTimeUtc',
|
||||
value: '4',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'messageId',
|
||||
value: '5',
|
||||
},
|
||||
{
|
||||
isSystemProperty: true,
|
||||
key: 'lockToken',
|
||||
value: '6',
|
||||
}
|
||||
];
|
||||
ServerBase.addPropertiesToCloudToDeviceMessage(message, properties);
|
||||
expect(message.ack).toEqual('1');
|
||||
expect(message.contentType).toEqual('json');
|
||||
expect(message.correlationId).toEqual('2');
|
||||
expect(message.contentEncoding).toEqual('3');
|
||||
expect(message.expiryTimeUtc).toEqual(4); // tslint:disable-line:no-magic-numbers
|
||||
expect(message.messageId).toEqual('5');
|
||||
expect(message.lockToken).toEqual('6');
|
||||
});
|
||||
});
|
||||
|
||||
context('findMatchingFile', () => {
|
||||
const model = {
|
||||
'@id': 'urn:azureiot:ModelDiscovery:ModelInformation;1',
|
||||
'@type': 'Interface',
|
||||
'displayName': 'Digital Twin Client SDK Information',
|
||||
'contents': [
|
||||
{
|
||||
'@type': 'Property',
|
||||
'name': 'language',
|
||||
'displayName': 'SDK Language',
|
||||
'schema': 'string',
|
||||
'description': 'The language for the Digital Twin client SDK. For example, Java.'
|
||||
}
|
||||
],
|
||||
'@context': 'http://azureiot.com/v1/contexts/IoTModel.json'
|
||||
};
|
||||
|
||||
it('returns null when @id and expected file name does not match', async () => {
|
||||
jest.spyOn(ServerBase, 'readFileFromLocal').mockReturnValue(JSON.stringify(model));
|
||||
expect(ServerBase.findMatchingFile('', ['test.json'], 'urn:azureiot:test:ModelInformation;1')).toEqual(null);
|
||||
});
|
||||
|
||||
it('returns model when @id and expected file name matches', async () => {
|
||||
jest.spyOn(ServerBase, 'readFileFromLocal').mockReturnValue(JSON.stringify(model));
|
||||
expect(ServerBase.findMatchingFile('', ['test.json'], 'urn:azureiot:ModelDiscovery:ModelInformation;1')).toEqual(JSON.stringify(model));
|
||||
});
|
||||
|
||||
it('returns model when @id and expected file name matches and model is an array', async () => {
|
||||
jest.spyOn(ServerBase, 'readFileFromLocal').mockReturnValue(JSON.stringify([model]));
|
||||
expect(ServerBase.findMatchingFile('', ['test.json'], 'urn:azureiot:ModelDiscovery:ModelInformation;1')).toEqual(model);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,23 +4,26 @@
|
|||
**********************************************************/
|
||||
// this file is the legacy controller for local development, until we move server side code to use electron's IPC pattern and enable electron hot reloading
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as WebSocket from 'ws';
|
||||
import express = require('express');
|
||||
import request = require('request');
|
||||
import bodyParser = require('body-parser');
|
||||
import cors = require('cors');
|
||||
import { Client as HubClient } from 'azure-iothub';
|
||||
import { Message as CloudToDeviceMessage } from 'azure-iot-common';
|
||||
import { EventHubClient, EventPosition, ReceiveHandler } from '@azure/event-hubs';
|
||||
import { EventHubConsumerClient, Subscription, ReceivedEventData } from '@azure/event-hubs';
|
||||
import { generateDataPlaneRequestBody, generateDataPlaneResponse } from './dataPlaneHelper';
|
||||
import { convertIotHubToEventHubsConnectionString } from './eventHubHelper';
|
||||
import { fetchDirectories, fetchDrivesOnWindows, findMatchingFile } from './utils';
|
||||
|
||||
const SERVER_ERROR = 500;
|
||||
export const SERVER_ERROR = 500;
|
||||
export const SUCCESS = 200;
|
||||
const BAD_REQUEST = 400;
|
||||
const SUCCESS = 200;
|
||||
const NOT_FOUND = 404;
|
||||
const NO_CONTENT_SUCCESS = 204;
|
||||
|
||||
let wss: WebSocket.Server;
|
||||
let ws: WebSocket.WebSocket;
|
||||
|
||||
interface Message {
|
||||
body: any; // tslint:disable-line:no-any
|
||||
enqueuedTime: string;
|
||||
|
@ -43,18 +46,32 @@ export class ServerBase {
|
|||
}));
|
||||
|
||||
app.post(dataPlaneUri, handleDataPlanePostRequest);
|
||||
app.post(cloudToDeviceUri, handleCloudToDevicePostRequest);
|
||||
app.post(eventHubMonitorUri, handleEventHubMonitorPostRequest);
|
||||
app.post(eventHubStopUri, handleEventHubStopPostRequest);
|
||||
app.post(modelRepoUri, handleModelRepoPostRequest);
|
||||
app.get(readFileUri, handleReadFileRequest);
|
||||
app.get(getDirectoriesUri, handleGetDirectoriesRequest);
|
||||
|
||||
app.listen(this.port).on('error', () => { throw new Error(
|
||||
//initialize a simple http server
|
||||
const server = http.createServer(app);
|
||||
|
||||
//start the server
|
||||
server.listen(this.port).on('error', () => { throw new Error(
|
||||
`Failed to start the app on port ${this.port} as it is in use.
|
||||
You can still view static pages, but requests cannot be made to the services if the port is still occupied.
|
||||
To get around with the issue, configure a custom port by setting the system environment variable 'AZURE_IOT_EXPLORER_PORT' to an available port number.
|
||||
To learn more, please visit https://github.com/Azure/azure-iot-explorer/wiki/FAQ`); });
|
||||
|
||||
//initialize the WebSocket server instance
|
||||
wss = new WebSocket.Server({ server });
|
||||
wss.on('connection', (_ws: WebSocket) => {
|
||||
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
||||
ws = _ws;
|
||||
}
|
||||
else {
|
||||
ws = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,49 +106,6 @@ export const handleReadFileRequest = (req: express.Request, res: express.Respons
|
|||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export const findMatchingFile = (filePath: string, fileNames: string[], expectedFileName: string): string => {
|
||||
const filesWithParsingError = [];
|
||||
for (const fileName of fileNames) {
|
||||
if (isFileExtensionJson(fileName)) {
|
||||
try {
|
||||
const data = readFileFromLocal(filePath, fileName);
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData) {
|
||||
if (Array.isArray(parsedData)) { // if parsedData is array, it is using expanded dtdl format
|
||||
for (const pd of parsedData) {
|
||||
if (pd['@id']?.toString() === expectedFileName) {
|
||||
return pd;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (parsedData['@id']?.toString() === expectedFileName) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
filesWithParsingError.push(`${fileName}: ${error.message}`); // swallow error and continue the loop
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filesWithParsingError.length > 0) {
|
||||
throw new Error(filesWithParsingError.join(', '));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readFileFromLocal = (filePath: string, fileName: string) => {
|
||||
return fs.readFileSync(`${filePath}/${fileName}`, 'utf-8');
|
||||
}
|
||||
|
||||
const isFileExtensionJson = (fileName: string) => {
|
||||
const i = fileName.lastIndexOf('.');
|
||||
return i > 0 && fileName.substr(i) === '.json';
|
||||
};
|
||||
|
||||
const getDirectoriesUri = '/api/Directories/:path';
|
||||
export const handleGetDirectoriesRequest = (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
|
@ -148,34 +122,6 @@ export const handleGetDirectoriesRequest = (req: express.Request, res: express.R
|
|||
}
|
||||
};
|
||||
|
||||
const fetchDrivesOnWindows = (res: express.Response) => {
|
||||
const exec = require('child_process').exec;
|
||||
exec('wmic logicaldisk get name', (error: any, stdout: any, stderr: any) => { // tslint:disable-line:no-any
|
||||
if (!error && !stderr) {
|
||||
res.status(SUCCESS).send(stdout);
|
||||
}
|
||||
else {
|
||||
res.status(SERVER_ERROR).send();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDirectories = (dir: string, res: express.Response) => {
|
||||
const result: string[] = [];
|
||||
for (const item of fs.readdirSync(dir)) {
|
||||
try {
|
||||
const stat = fs.statSync(path.join(dir, item));
|
||||
if (stat.isDirectory()) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// some item cannot be checked by isDirectory(), swallow error and continue the loop
|
||||
}
|
||||
}
|
||||
res.status(SUCCESS).send(result);
|
||||
};
|
||||
|
||||
const dataPlaneUri = '/api/DataPlane';
|
||||
export const handleDataPlanePostRequest = (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
|
@ -196,52 +142,19 @@ export const handleDataPlanePostRequest = (req: express.Request, res: express.Re
|
|||
}
|
||||
};
|
||||
|
||||
const cloudToDeviceUri = '/api/CloudToDevice';
|
||||
export const handleCloudToDevicePostRequest = (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
if (!req.body) {
|
||||
res.status(BAD_REQUEST).send();
|
||||
}
|
||||
else {
|
||||
const hubClient = HubClient.fromConnectionString(req.body.connectionString);
|
||||
hubClient.open(() => {
|
||||
const message = new CloudToDeviceMessage(req.body.body);
|
||||
addPropertiesToCloudToDeviceMessage(message, req.body.properties);
|
||||
hubClient.send(req.body.deviceId, message, (err, result) => {
|
||||
if (err) {
|
||||
res.status(SERVER_ERROR).send(err);
|
||||
} else {
|
||||
res.status(SUCCESS).send(result);
|
||||
}
|
||||
hubClient.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
res.status(SERVER_ERROR).send(error);
|
||||
}
|
||||
};
|
||||
|
||||
const eventHubMonitorUri = '/api/EventHub/monitor';
|
||||
const IOTHUB_CONNECTION_DEVICE_ID = 'iothub-connection-device-id';
|
||||
const IOTHUB_CONNECTION_MODULE_ID = 'iothub-connection-module-id';
|
||||
let client: EventHubClient = null;
|
||||
let messages: Message[] = [];
|
||||
let receivers: ReceiveHandler[] = [];
|
||||
let connectionString: string = ''; // would equal `${hubConnectionString}` or `${customEventHubConnectionString}/${customEventHubName}`
|
||||
let deviceId: string = '';
|
||||
let moduleId: string = '';
|
||||
let client: EventHubConsumerClient = null;
|
||||
let subscription: Subscription = null;
|
||||
export const handleEventHubMonitorPostRequest = (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
if (!req.body) {
|
||||
res.status(BAD_REQUEST).send();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
eventHubProvider(req.body).then(result => {
|
||||
res.status(SUCCESS).send(result);
|
||||
initializeEventHubClient(req.body).then(() => {
|
||||
res.status(SUCCESS).send([]);
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(SERVER_ERROR).send(error);
|
||||
|
@ -256,8 +169,9 @@ export const handleEventHubStopPostRequest = (req: express.Request, res: express
|
|||
return;
|
||||
}
|
||||
|
||||
stopClient();
|
||||
res.status(SUCCESS).send();
|
||||
stopClient().then(() => {
|
||||
res.status(SUCCESS).send();
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(SERVER_ERROR).send(error);
|
||||
}
|
||||
|
@ -285,172 +199,54 @@ export const handleModelRepoPostRequest = (req: express.Request, res: express.Re
|
|||
}
|
||||
});
|
||||
} catch (error) {
|
||||
stopReceivers();
|
||||
res.status(SERVER_ERROR).send(error);
|
||||
}
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export const addPropertiesToCloudToDeviceMessage = (message: CloudToDeviceMessage, properties: Array<{key: string, value: string, isSystemProperty: boolean}>) => {
|
||||
if (!properties || properties.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const property of properties) {
|
||||
if (property.isSystemProperty) {
|
||||
switch (property.key) {
|
||||
case 'ack':
|
||||
message.ack = property.value;
|
||||
break;
|
||||
case 'contentType':
|
||||
message.contentType = property.value as any; // tslint:disable-line:no-any
|
||||
break;
|
||||
case 'correlationId':
|
||||
message.correlationId = property.value;
|
||||
break;
|
||||
case 'contentEncoding':
|
||||
message.contentEncoding = property.value as any; // tslint:disable-line:no-any
|
||||
break;
|
||||
case 'expiryTimeUtc':
|
||||
message.expiryTimeUtc = parseInt(property.value); // tslint:disable-line:radix
|
||||
break;
|
||||
case 'messageId':
|
||||
message.messageId = property.value;
|
||||
break;
|
||||
case 'lockToken':
|
||||
message.lockToken = property.value;
|
||||
break;
|
||||
default:
|
||||
message.properties.add(property.key, property.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else {
|
||||
message.properties.add(property.key, property.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const eventHubProvider = async (params: any) => {
|
||||
await initializeEventHubClient(params);
|
||||
updateEntityIdIfNecessary(params);
|
||||
|
||||
return listeningToMessages(params);
|
||||
};
|
||||
|
||||
const initializeEventHubClient = async (params: any) => {
|
||||
if (needToCreateNewEventHubClient(params))
|
||||
{
|
||||
// hub has changed, reinitialize client, receivers and messages
|
||||
if (params.customEventHubConnectionString) {
|
||||
client = await EventHubClient.createFromConnectionString(params.customEventHubConnectionString, params.customEventHubName);
|
||||
}
|
||||
else {
|
||||
client = await EventHubClient.createFromConnectionString(await convertIotHubToEventHubsConnectionString(params.hubConnectionString));
|
||||
}
|
||||
|
||||
connectionString = params.customEventHubConnectionString ?
|
||||
`${params.customEventHubConnectionString}/${params.customEventHubName}` :
|
||||
params.hubConnectionString;
|
||||
receivers = [];
|
||||
messages = [];
|
||||
if (params.customEventHubConnectionString) {
|
||||
client = new EventHubConsumerClient(params.consumerGroup, params.customEventHubConnectionString);
|
||||
}
|
||||
};
|
||||
|
||||
const listeningToMessages = async (params: any) => {
|
||||
if (params.startListeners || !receivers) {
|
||||
const partitionIds = await client.getPartitionIds();
|
||||
const hubInfo = await client.getHubRuntimeInformation();
|
||||
const startTime = params.startTime ? Date.parse(params.startTime) : Date.now();
|
||||
|
||||
partitionIds && partitionIds.forEach(async (partitionId: string) => {
|
||||
const receiveOptions = {
|
||||
consumerGroup: params.consumerGroup,
|
||||
enableReceiverRuntimeMetric: true,
|
||||
eventPosition: EventPosition.fromEnqueuedTime(startTime),
|
||||
name: `${hubInfo.path}_${partitionId}`,
|
||||
};
|
||||
|
||||
const receiver = client.receive(
|
||||
partitionId,
|
||||
onMessageReceived,
|
||||
(err: object) => {},
|
||||
receiveOptions);
|
||||
receivers.push(receiver);
|
||||
});
|
||||
else {
|
||||
client = new EventHubConsumerClient(params.consumerGroup, await convertIotHubToEventHubsConnectionString(params.hubConnectionString));
|
||||
}
|
||||
|
||||
return handleMessages();
|
||||
};
|
||||
|
||||
const handleMessages = () => {
|
||||
let results: Message[] = [];
|
||||
messages.forEach(message => {
|
||||
if (!results.some(result => result.systemProperties?.['x-opt-sequence-number'] === message.systemProperties?.['x-opt-sequence-number'])) {
|
||||
// if user click stop/start too frequently, it's possible duplicate receivers are created before the cleanup happens as it's async
|
||||
// remove duplicate messages before proper cleanup is finished
|
||||
results.push(message);
|
||||
}
|
||||
})
|
||||
messages = []; // empty the array every time the result is returned
|
||||
return results;
|
||||
}
|
||||
|
||||
export const stopClient = async () => {
|
||||
return stopReceivers().then(() => {
|
||||
return client && client.close().catch(error => {
|
||||
console.log(`client cleanup error: ${error}`); // swallow the error as we will cleanup anyways
|
||||
});
|
||||
}).finally (() => {
|
||||
client = null;
|
||||
receivers = [];
|
||||
});
|
||||
};
|
||||
|
||||
const stopReceivers = async () => {
|
||||
return Promise.all(
|
||||
receivers.map(receiver => {
|
||||
if (receiver && (receiver.isReceiverOpen === undefined || receiver.isReceiverOpen)) {
|
||||
return stopReceiver(receiver);
|
||||
} else {
|
||||
return null;
|
||||
subscription = client.subscribe(
|
||||
{
|
||||
processEvents: async (events) => {
|
||||
handleMessages(events, params)
|
||||
},
|
||||
processError: async (err) => {
|
||||
console.log(err);
|
||||
}
|
||||
})
|
||||
},
|
||||
{ startPosition: params.startTime ? { enqueuedOn: new Date(params.startTime).getTime() } : { enqueuedOn: new Date() } }
|
||||
);
|
||||
};
|
||||
|
||||
const stopReceiver = async (receiver: ReceiveHandler) => {
|
||||
receiver.stop().catch((err: object) => {
|
||||
throw new Error(`receivers cleanup error: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
const needToCreateNewEventHubClient = (parmas: any): boolean => {
|
||||
return !client ||
|
||||
parmas.hubConnectionString && parmas.hubConnectionString !== connectionString ||
|
||||
parmas.customEventHubConnectionString && `${parmas.customEventHubConnectionString}/${parmas.customEventHubName}` !== connectionString;
|
||||
}
|
||||
|
||||
const updateEntityIdIfNecessary = (parmas: any) => {
|
||||
if (!deviceId || parmas.deviceId !== deviceId) {
|
||||
deviceId = parmas.deviceId;
|
||||
messages = [];
|
||||
}
|
||||
if (parmas.moduleId !== moduleId) {
|
||||
moduleId = parmas.moduleId;
|
||||
messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
const onMessageReceived = async (eventData: any) => {
|
||||
if (eventData?.annotations?.[IOTHUB_CONNECTION_DEVICE_ID] === deviceId) {
|
||||
if (!moduleId || eventData?.annotations?.[IOTHUB_CONNECTION_MODULE_ID] === moduleId) {
|
||||
const message: Message = {
|
||||
body: eventData.body,
|
||||
enqueuedTime: eventData.enqueuedTimeUtc.toString(),
|
||||
properties: eventData.applicationProperties
|
||||
};
|
||||
message.systemProperties = eventData.annotations;
|
||||
messages.push(message);
|
||||
const handleMessages = (events: ReceivedEventData[], params: any) => {
|
||||
const messages: Message[] = [];
|
||||
events.forEach(event => {
|
||||
if (event?.systemProperties?.[IOTHUB_CONNECTION_DEVICE_ID] === params.deviceId) {
|
||||
if (!params.moduleId || event?.systemProperties?.[IOTHUB_CONNECTION_MODULE_ID] === params.moduleId) {
|
||||
const message: Message = {
|
||||
body: event.body,
|
||||
enqueuedTime: event.enqueuedTimeUtc.toString(),
|
||||
properties: event.properties
|
||||
};
|
||||
message.systemProperties = event.systemProperties;
|
||||
if (messages.find(item => item.enqueuedTime >= message.enqueuedTime))
|
||||
return; // do not push message if enqueuedTime is earlier than any existing message
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (messages.length >= 1) {
|
||||
ws.send(JSON.stringify(messages));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const stopClient = async () => {
|
||||
console.log('stop client');
|
||||
await subscription?.close();
|
||||
await client?.close();
|
||||
};
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/***********************************************************
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License
|
||||
**********************************************************/
|
||||
import 'jest';
|
||||
import * as Utils from './utils';
|
||||
|
||||
describe('Utils', () => {
|
||||
context('findMatchingFile', () => {
|
||||
const model = {
|
||||
'@id': 'urn:azureiot:ModelDiscovery:ModelInformation;1',
|
||||
'@type': 'Interface',
|
||||
'displayName': 'Digital Twin Client SDK Information',
|
||||
'contents': [
|
||||
{
|
||||
'@type': 'Property',
|
||||
'name': 'language',
|
||||
'displayName': 'SDK Language',
|
||||
'schema': 'string',
|
||||
'description': 'The language for the Digital Twin client SDK. For example, Java.'
|
||||
}
|
||||
],
|
||||
'@context': 'http://azureiot.com/v1/contexts/IoTModel.json'
|
||||
};
|
||||
|
||||
it('returns null when @id and expected file name does not match', async () => {
|
||||
jest.spyOn(Utils, 'readFileFromLocal').mockReturnValue(JSON.stringify(model));
|
||||
expect(Utils.findMatchingFile('', ['test.json'], 'urn:azureiot:test:ModelInformation;1')).toEqual(null);
|
||||
});
|
||||
|
||||
it('returns model when @id and expected file name matches', async () => {
|
||||
jest.spyOn(Utils, 'readFileFromLocal').mockReturnValue(JSON.stringify(model));
|
||||
expect(Utils.findMatchingFile('', ['test.json'], 'urn:azureiot:ModelDiscovery:ModelInformation;1')).toEqual(JSON.stringify(model));
|
||||
});
|
||||
|
||||
it('returns model when @id and expected file name matches and model is an array', async () => {
|
||||
jest.spyOn(Utils, 'readFileFromLocal').mockReturnValue(JSON.stringify([model]));
|
||||
expect(Utils.findMatchingFile('', ['test.json'], 'urn:azureiot:ModelDiscovery:ModelInformation;1')).toEqual(model);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
import express = require('express');
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SERVER_ERROR, SUCCESS } from './serverBase';
|
||||
|
||||
export const fetchDrivesOnWindows = (res: express.Response) => {
|
||||
const exec = require('child_process').exec;
|
||||
exec('wmic logicaldisk get name', (error: any, stdout: any, stderr: any) => { // tslint:disable-line:no-any
|
||||
if (!error && !stderr) {
|
||||
res.status(SUCCESS).send(stdout);
|
||||
}
|
||||
else {
|
||||
res.status(SERVER_ERROR).send();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchDirectories = (dir: string, res: express.Response) => {
|
||||
const result: string[] = [];
|
||||
for (const item of fs.readdirSync(dir)) {
|
||||
try {
|
||||
const stat = fs.statSync(path.join(dir, item));
|
||||
if (stat.isDirectory()) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// some item cannot be checked by isDirectory(), swallow error and continue the loop
|
||||
}
|
||||
}
|
||||
res.status(SUCCESS).send(result);
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export const findMatchingFile = (filePath: string, fileNames: string[], expectedFileName: string): string => {
|
||||
const filesWithParsingError = [];
|
||||
for (const fileName of fileNames) {
|
||||
if (isFileExtensionJson(fileName)) {
|
||||
try {
|
||||
const data = readFileFromLocal(filePath, fileName);
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData) {
|
||||
if (Array.isArray(parsedData)) { // if parsedData is array, it is using expanded dtdl format
|
||||
for (const pd of parsedData) {
|
||||
if (pd['@id']?.toString() === expectedFileName) {
|
||||
return pd;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (parsedData['@id']?.toString() === expectedFileName) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
filesWithParsingError.push(`${fileName}: ${error.message}`); // swallow error and continue the loop
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filesWithParsingError.length > 0) {
|
||||
throw new Error(filesWithParsingError.join(', '));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isFileExtensionJson = (fileName: string) => {
|
||||
const i = fileName.lastIndexOf('.');
|
||||
return i > 0 && fileName.substr(i) === '.json';
|
||||
};
|
||||
|
||||
export const readFileFromLocal = (filePath: string, fileName: string) => {
|
||||
return fs.readFileSync(`${filePath}/${fileName}`, 'utf-8');
|
||||
}
|
Загрузка…
Ссылка в новой задаче