diff --git a/app-shell/src/system-info/__tests__/dispatch.test.js b/app-shell/src/system-info/__tests__/dispatch.test.js index c82d626f80c..3c053404a71 100644 --- a/app-shell/src/system-info/__tests__/dispatch.test.js +++ b/app-shell/src/system-info/__tests__/dispatch.test.js @@ -6,6 +6,7 @@ import * as SystemInfo from '@opentrons/app/src/system-info' import { uiInitialized } from '@opentrons/app/src/shell' import * as OS from '../../os' import * as UsbDevices from '../usb-devices' +import * as NetworkInterfaces from '../network-interfaces' import { registerSystemInfo } from '..' import type { @@ -14,8 +15,15 @@ import type { UsbDeviceMonitorOptions, } from '../usb-devices' +import type { + NetworkInterface, + NetworkInterfaceMonitor, + NetworkInterfaceMonitorOptions, +} from '../network-interfaces' + jest.mock('../../os') jest.mock('../usb-devices') +jest.mock('../network-interfaces') const createUsbDeviceMonitor: JestMockFn< [UsbDeviceMonitorOptions | void], @@ -25,6 +33,14 @@ const createUsbDeviceMonitor: JestMockFn< const getWindowsDriverVersion: JestMockFn<[Device], any> = UsbDevices.getWindowsDriverVersion +const getActiveInterfaces: JestMockFn<[], Array> = + NetworkInterfaces.getActiveInterfaces + +const createNetworkInterfaceMonitor: JestMockFn< + [NetworkInterfaceMonitorOptions], + NetworkInterfaceMonitor +> = NetworkInterfaces.createNetworkInterfaceMonitor + const isWindows: JestMockFn<[], boolean> = OS.isWindows const flush = () => new Promise(resolve => setTimeout(resolve, 0)) @@ -32,8 +48,8 @@ const flush = () => new Promise(resolve => setTimeout(resolve, 0)) describe('app-shell::system-info module action tests', () => { const dispatch = jest.fn() const getAllDevices: JestMockFn<[], any> = jest.fn() - const stop = jest.fn() - const monitor: $Shape = { getAllDevices, stop } + const usbMonitor: UsbDeviceMonitor = { getAllDevices, stop: jest.fn() } + const ifaceMonitor: NetworkInterfaceMonitor = { stop: jest.fn() } const { windowsDriverVersion: _, ...notRealtek } = Fixtures.mockUsbDevice const realtek0 = { ...notRealtek, manufacturer: 'Realtek' } const realtek1 = { ...notRealtek, manufacturer: 'realtek' } @@ -42,19 +58,29 @@ describe('app-shell::system-info module action tests', () => { beforeEach(() => { handler = registerSystemInfo(dispatch) isWindows.mockReturnValue(false) - createUsbDeviceMonitor.mockReturnValue(monitor) + createUsbDeviceMonitor.mockReturnValue(usbMonitor) + createNetworkInterfaceMonitor.mockReturnValue(ifaceMonitor) getAllDevices.mockResolvedValue([realtek0]) + getActiveInterfaces.mockReturnValue([ + Fixtures.mockNetworkInterface, + Fixtures.mockNetworkInterfaceV6, + ]) }) afterEach(() => { jest.resetAllMocks() }) - it('sends initial USB device list on shell:UI_INITIALIZED', () => { + it('sends initial USB device and network list on shell:UI_INITIALIZED', () => { handler(uiInitialized()) return flush().then(() => { - expect(dispatch).toHaveBeenCalledWith(SystemInfo.initialized([realtek0])) + expect(dispatch).toHaveBeenCalledWith( + SystemInfo.initialized( + [realtek0], + [Fixtures.mockNetworkInterface, Fixtures.mockNetworkInterfaceV6] + ) + ) expect(getWindowsDriverVersion).toHaveBeenCalledTimes(0) }) }) @@ -65,16 +91,17 @@ describe('app-shell::system-info module action tests', () => { return flush().then(() => { expect(createUsbDeviceMonitor).toHaveBeenCalledTimes(1) - expect(dispatch).toHaveBeenCalledTimes(1) + expect(createNetworkInterfaceMonitor).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(2) }) }) it('sends systemInfo:USB_DEVICE_ADDED when device added', () => { handler(uiInitialized()) - const monitorOptions = createUsbDeviceMonitor.mock.calls[0][0] + const usbMonitorOptions = createUsbDeviceMonitor.mock.calls[0][0] - expect(monitorOptions?.onDeviceAdd).toEqual(expect.any(Function)) - const onDeviceAdd = monitorOptions?.onDeviceAdd ?? noop + expect(usbMonitorOptions?.onDeviceAdd).toEqual(expect.any(Function)) + const onDeviceAdd = usbMonitorOptions?.onDeviceAdd ?? noop onDeviceAdd(realtek0) return flush().then(() => { @@ -85,10 +112,10 @@ describe('app-shell::system-info module action tests', () => { it('sends systemInfo:USB_DEVICE_REMOVED when device removed', () => { handler(uiInitialized()) - const monitorOptions = createUsbDeviceMonitor.mock.calls[0][0] + const usbMonitorOptions = createUsbDeviceMonitor.mock.calls[0][0] - expect(monitorOptions?.onDeviceRemove).toEqual(expect.any(Function)) - const onDeviceRemove = monitorOptions?.onDeviceRemove ?? noop + expect(usbMonitorOptions?.onDeviceRemove).toEqual(expect.any(Function)) + const onDeviceRemove = usbMonitorOptions?.onDeviceRemove ?? noop onDeviceRemove(realtek0) return flush().then(() => { @@ -98,6 +125,28 @@ describe('app-shell::system-info module action tests', () => { }) }) + it('sends systemInfo:NETWORK_INTERFACES_CHANGED when ifaces change', () => { + handler(uiInitialized()) + const ifaceMonitorOpts = createNetworkInterfaceMonitor.mock.calls[0][0] + + expect(ifaceMonitorOpts.onInterfaceChange).toEqual(expect.any(Function)) + const { onInterfaceChange } = ifaceMonitorOpts + + onInterfaceChange([ + Fixtures.mockNetworkInterface, + Fixtures.mockNetworkInterfaceV6, + ]) + + return flush().then(() => { + expect(dispatch).toHaveBeenCalledWith( + SystemInfo.networkInterfacesChanged([ + Fixtures.mockNetworkInterface, + Fixtures.mockNetworkInterfaceV6, + ]) + ) + }) + }) + it('stops monitoring on app quit', () => { handler(uiInitialized()) @@ -107,7 +156,8 @@ describe('app-shell::system-info module action tests', () => { expect(typeof appQuitHandler).toBe('function') appQuitHandler() - expect(monitor.stop).toHaveBeenCalled() + expect(usbMonitor.stop).toHaveBeenCalled() + expect(ifaceMonitor.stop).toHaveBeenCalled() }) describe('on windows', () => { @@ -125,11 +175,14 @@ describe('app-shell::system-info module action tests', () => { expect(getWindowsDriverVersion).toHaveBeenCalledWith(realtek1) expect(dispatch).toHaveBeenCalledWith( - SystemInfo.initialized([ - { ...realtek0, windowsDriverVersion: '1.2.3' }, - notRealtek, - { ...realtek1, windowsDriverVersion: '1.2.3' }, - ]) + SystemInfo.initialized( + [ + { ...realtek0, windowsDriverVersion: '1.2.3' }, + notRealtek, + { ...realtek1, windowsDriverVersion: '1.2.3' }, + ], + [Fixtures.mockNetworkInterface, Fixtures.mockNetworkInterfaceV6] + ) ) }) }) @@ -137,8 +190,8 @@ describe('app-shell::system-info module action tests', () => { it('should add Windows driver versions to Realtek devices on add', () => { getAllDevices.mockResolvedValue([]) handler(uiInitialized()) - const monitorOptions = createUsbDeviceMonitor.mock.calls[0][0] - const onDeviceAdd = monitorOptions?.onDeviceAdd ?? noop + const usbMonitorOptions = createUsbDeviceMonitor.mock.calls[0][0] + const onDeviceAdd = usbMonitorOptions?.onDeviceAdd ?? noop onDeviceAdd(realtek0) return flush().then(() => { diff --git a/app-shell/src/system-info/__tests__/network-interfaces.test.js b/app-shell/src/system-info/__tests__/network-interfaces.test.js new file mode 100644 index 00000000000..1c8d72d1b12 --- /dev/null +++ b/app-shell/src/system-info/__tests__/network-interfaces.test.js @@ -0,0 +1,135 @@ +// @flow +import os from 'os' +import noop from 'lodash/noop' + +import { + getActiveInterfaces, + createNetworkInterfaceMonitor, +} from '../network-interfaces' + +jest.mock('os') + +const networkInterfaces: JestMockFn< + [], + { [ifName: string]: Array, ... } +> = os.networkInterfaces + +const mockV4 = { + address: '192.168.1.17', + netmask: '255.255.255.0', + family: 'IPv4', + mac: 'f8:ff:c2:46:59:80', + internal: false, + cidr: '192.168.1.17/24', +} + +const mockV6 = { + address: 'fe80::8e0:61a3:8bde:7385', + netmask: 'ffff:ffff:ffff:ffff::', + family: 'IPv6', + mac: 'f8:ff:c2:46:59:80', + internal: false, + cidr: 'fe80::8e0:61a3:8bde:7385/64', + scopeid: 6, +} + +describe('system-info::network-interfaces', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.resetAllMocks() + jest.clearAllTimers() + jest.useRealTimers() + }) + + it('should return external network interfaces', () => { + networkInterfaces.mockReturnValue({ + en0: [mockV4, mockV6], + en1: [mockV6], + lo0: [{ ...mockV4, internal: true }, { ...mockV6, internal: true }], + }) + + expect(getActiveInterfaces()).toEqual([ + { name: 'en0', ...mockV4 }, + { name: 'en0', ...mockV6 }, + { name: 'en1', ...mockV6 }, + ]) + }) + + it('should be able to poll the attached network interfaces', () => { + networkInterfaces.mockReturnValue({}) + + const monitor = createNetworkInterfaceMonitor({ + pollInterval: 30000, + onInterfaceChange: noop, + }) + + expect(networkInterfaces).toHaveBeenCalledTimes(1) + jest.advanceTimersByTime(30000) + expect(networkInterfaces).toHaveBeenCalledTimes(2) + jest.advanceTimersByTime(30000) + expect(networkInterfaces).toHaveBeenCalledTimes(3) + + monitor.stop() + jest.advanceTimersByTime(30000) + expect(networkInterfaces).toHaveBeenCalledTimes(3) + }) + + it('should be able to signal interface changes', () => { + const handleInterfaceChange = jest.fn() + + networkInterfaces.mockReturnValue({}) + + createNetworkInterfaceMonitor({ + pollInterval: 30000, + onInterfaceChange: handleInterfaceChange, + }) + + networkInterfaces.mockReturnValueOnce({ + en0: [mockV4, mockV6], + }) + jest.advanceTimersByTime(30000) + expect(handleInterfaceChange).toHaveBeenCalledWith([ + { name: 'en0', ...mockV4 }, + { name: 'en0', ...mockV6 }, + ]) + handleInterfaceChange.mockClear() + + networkInterfaces.mockReturnValueOnce({ + en0: [mockV4, mockV6], + }) + jest.advanceTimersByTime(30000) + expect(handleInterfaceChange).toHaveBeenCalledTimes(0) + handleInterfaceChange.mockClear() + + networkInterfaces.mockReturnValueOnce({ + en0: [mockV4, mockV6], + en1: [mockV4], + }) + jest.advanceTimersByTime(30000) + expect(handleInterfaceChange).toHaveBeenCalledWith([ + { name: 'en0', ...mockV4 }, + { name: 'en0', ...mockV6 }, + { name: 'en1', ...mockV4 }, + ]) + handleInterfaceChange.mockClear() + }) + + it('should be able to stop monitoring interface changes', () => { + const handleInterfaceChange = jest.fn() + + networkInterfaces.mockReturnValue({}) + + const monitor = createNetworkInterfaceMonitor({ + pollInterval: 30000, + onInterfaceChange: handleInterfaceChange, + }) + + networkInterfaces.mockReturnValueOnce({ en0: [mockV4] }) + monitor.stop() + jest.advanceTimersByTime(30000) + expect(handleInterfaceChange).toHaveBeenCalledTimes(0) + }) +}) diff --git a/app-shell/src/system-info/index.js b/app-shell/src/system-info/index.js index 3a87d675466..492f3a68c34 100644 --- a/app-shell/src/system-info/index.js +++ b/app-shell/src/system-info/index.js @@ -6,12 +6,18 @@ import * as SystemInfo from '@opentrons/app/src/system-info' import { createLogger } from '../log' import { isWindows } from '../os' import { createUsbDeviceMonitor, getWindowsDriverVersion } from './usb-devices' +import { + createNetworkInterfaceMonitor, + getActiveInterfaces, +} from './network-interfaces' import type { UsbDevice } from '@opentrons/app/src/system-info/types' import type { Action, Dispatch } from '../types' import type { UsbDeviceMonitor, Device } from './usb-devices' +import type { NetworkInterfaceMonitor } from './network-interfaces' const RE_REALTEK = /realtek/i +const IFACE_POLL_INTERVAL_MS = 30000 const log = createLogger('system-info') @@ -27,7 +33,8 @@ const addDriverVersion = (device: Device): Promise => { } export function registerSystemInfo(dispatch: Dispatch) { - let monitor: UsbDeviceMonitor + let usbMonitor: UsbDeviceMonitor + let ifaceMonitor: NetworkInterfaceMonitor const handleDeviceAdd = device => { addDriverVersion(device).then(d => dispatch(SystemInfo.usbDeviceAdded(d))) @@ -37,27 +44,45 @@ export function registerSystemInfo(dispatch: Dispatch) { dispatch(SystemInfo.usbDeviceRemoved({ ...d })) } + const handleIfacesChanged = interfaces => { + dispatch(SystemInfo.networkInterfacesChanged(interfaces)) + } + app.once('will-quit', () => { - if (monitor) { + if (usbMonitor) { log.debug('stopping usb monitoring') - monitor.stop() + usbMonitor.stop() + } + + if (ifaceMonitor) { + log.debug('stopping network iface monitoring') + ifaceMonitor.stop() } }) return function handleSystemAction(action: Action) { switch (action.type) { case UI_INITIALIZED: { - if (!monitor) { - monitor = createUsbDeviceMonitor({ + usbMonitor = + usbMonitor ?? + createUsbDeviceMonitor({ onDeviceAdd: handleDeviceAdd, onDeviceRemove: handleDeviceRemove, }) - monitor - .getAllDevices() - .then(devices => Promise.all(devices.map(addDriverVersion))) - .then(devices => dispatch(SystemInfo.initialized(devices))) - } + ifaceMonitor = + ifaceMonitor ?? + createNetworkInterfaceMonitor({ + pollInterval: IFACE_POLL_INTERVAL_MS, + onInterfaceChange: handleIfacesChanged, + }) + + usbMonitor + .getAllDevices() + .then(devices => Promise.all(devices.map(addDriverVersion))) + .then(devices => { + dispatch(SystemInfo.initialized(devices, getActiveInterfaces())) + }) } } } diff --git a/app-shell/src/system-info/network-interfaces.js b/app-shell/src/system-info/network-interfaces.js new file mode 100644 index 00000000000..54e79292cfc --- /dev/null +++ b/app-shell/src/system-info/network-interfaces.js @@ -0,0 +1,46 @@ +// @flow +import os from 'os' +import isEqual from 'lodash/isEqual' + +import type { NetworkInterface } from '@opentrons/app/src/system-info/types' + +export type { NetworkInterface } + +export type NetworkInterfaceMonitorOptions = {| + pollInterval: number, + onInterfaceChange: (ifaces: Array) => mixed, +|} + +export type NetworkInterfaceMonitor = {| + stop: () => void, +|} + +export function getActiveInterfaces(): Array { + const ifaces = os.networkInterfaces() + + return Object.keys(ifaces).flatMap((name: string) => { + // $FlowFixMe(mc, 2020-05-27): Flow def of os.networkInterfaces return is incomplete + return ifaces[name] + .filter(iface => !iface.internal) + .map(iface => ({ ...iface, name })) + }) +} + +export function createNetworkInterfaceMonitor( + options: NetworkInterfaceMonitorOptions +): NetworkInterfaceMonitor { + const { pollInterval, onInterfaceChange } = options + let ifaces = getActiveInterfaces() + + const pollId = setInterval(monitorActiveInterfaces, pollInterval) + + return { stop: () => clearInterval(pollId) } + + function monitorActiveInterfaces() { + const nextIfaces = getActiveInterfaces() + if (!isEqual(ifaces, nextIfaces)) { + ifaces = nextIfaces + onInterfaceChange(ifaces) + } + } +} diff --git a/app/src/analytics/__tests__/system-info-events.test.js b/app/src/analytics/__tests__/system-info-events.test.js index 191a0d9aadb..e3238c023a3 100644 --- a/app/src/analytics/__tests__/system-info-events.test.js +++ b/app/src/analytics/__tests__/system-info-events.test.js @@ -5,84 +5,106 @@ import { makeEvent } from '../make-event' import * as SystemInfo from '../../system-info' import * as Fixtures from '../../system-info/__fixtures__' -import type { State, Action } from '../../types' -import type { AnalyticsEvent } from '../types' +import type { State } from '../../types' +import type { U2EAnalyticsProps } from '../../system-info/types' -type EventSpec = {| - should: string, - action: Action, - expected: AnalyticsEvent | null, -|} +jest.mock('../../system-info/selectors') -const MOCK_STATE: State = ({ mockState: true }: any) +const getU2EDeviceAnalyticsProps: JestMockFn< + [State], + U2EAnalyticsProps | null +> = SystemInfo.getU2EDeviceAnalyticsProps -const { mockWindowsRealtekDevice, mockRealtekDevice, mockUsbDevice } = Fixtures - -const SPECS: Array = [ - { - should: 'ignore systemInfo:INITIALIZED without Realtek devices', - action: SystemInfo.initialized([mockUsbDevice]), - expected: null, - }, - { - should: 'ignores systemInfo:USB_DEVICE_ADDED without Realtek devices', - action: SystemInfo.usbDeviceAdded(mockUsbDevice), - expected: null, - }, - { - should: 'add Realtek info to super props on systemInfo:INITIALIZED', - action: SystemInfo.initialized([mockRealtekDevice]), - expected: { - superProperties: { - 'U2E Vendor ID': mockRealtekDevice.vendorId, - 'U2E Product ID': mockRealtekDevice.productId, - 'U2E Serial Number': mockRealtekDevice.serialNumber, - 'U2E Device Name': mockRealtekDevice.deviceName, - 'U2E Manufacturer': mockRealtekDevice.manufacturer, - }, - }, - }, - { - should: 'add Realtek info to super props on systemInfo:USB_DEVICE_ADDED', - action: SystemInfo.usbDeviceAdded(mockRealtekDevice), - expected: { - superProperties: { - 'U2E Vendor ID': mockRealtekDevice.vendorId, - 'U2E Product ID': mockRealtekDevice.productId, - 'U2E Serial Number': mockRealtekDevice.serialNumber, - 'U2E Device Name': mockRealtekDevice.deviceName, - 'U2E Manufacturer': mockRealtekDevice.manufacturer, - }, - }, - }, - { - should: 'include Realtek windows driver version on systemInfo:INITIALIZED', - action: SystemInfo.initialized([mockWindowsRealtekDevice]), - expected: { - superProperties: expect.objectContaining({ - 'U2E Windows Driver Version': - mockWindowsRealtekDevice.windowsDriverVersion, - }), - }, - }, - { - should: - 'include Realtek windows driver version on systemInfo:USB_DEVICE_ADDED', - action: SystemInfo.usbDeviceAdded(mockWindowsRealtekDevice), - expected: { - superProperties: expect.objectContaining({ - 'U2E Windows Driver Version': - mockWindowsRealtekDevice.windowsDriverVersion, - }), - }, - }, -] +const MOCK_STATE: State = ({ mockState: true }: any) +const MOCK_ANALYTICS_PROPS = { + 'U2E Vendor ID': Fixtures.mockRealtekDevice.vendorId, + 'U2E Product ID': Fixtures.mockRealtekDevice.productId, + 'U2E Serial Number': Fixtures.mockRealtekDevice.serialNumber, + 'U2E Manufacturer': Fixtures.mockRealtekDevice.manufacturer, + 'U2E Device Name': Fixtures.mockRealtekDevice.deviceName, + 'U2E IPv4 Address': '10.0.0.1', +} describe('custom labware analytics events', () => { - SPECS.forEach(spec => { - const { should, action, expected } = spec - it(`should ${should}`, () => { - return expect(makeEvent(action, MOCK_STATE)).resolves.toEqual(expected) + beforeEach(() => { + getU2EDeviceAnalyticsProps.mockImplementation(state => { + expect(state).toBe(MOCK_STATE) + return MOCK_ANALYTICS_PROPS }) }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should trigger an event on systemInfo:INITIALIZED', () => { + const action = SystemInfo.initialized([Fixtures.mockRealtekDevice], []) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual({ + superProperties: { ...MOCK_ANALYTICS_PROPS, 'U2E IPv4 Address': true }, + }) + }) + + it('should trigger an event on systemInfo:USB_DEVICE_ADDED', () => { + const action = SystemInfo.usbDeviceAdded(Fixtures.mockRealtekDevice) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual({ + superProperties: { ...MOCK_ANALYTICS_PROPS, 'U2E IPv4 Address': true }, + }) + }) + + it('should trigger an event on systemInfo:NETWORK_INTERFACES_CHANGED', () => { + const action = SystemInfo.networkInterfacesChanged([ + Fixtures.mockNetworkInterface, + ]) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual({ + superProperties: { ...MOCK_ANALYTICS_PROPS, 'U2E IPv4 Address': true }, + }) + }) + + it('maps no assigned IPv4 address to false', () => { + getU2EDeviceAnalyticsProps.mockReturnValue({ + ...MOCK_ANALYTICS_PROPS, + 'U2E IPv4 Address': null, + }) + + const action = SystemInfo.initialized([Fixtures.mockRealtekDevice], []) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual({ + superProperties: { ...MOCK_ANALYTICS_PROPS, 'U2E IPv4 Address': false }, + }) + }) + + it('should not trigger on systemInfo:INITIALIZED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.initialized([Fixtures.mockRealtekDevice], []) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual(null) + }) + + it('should not trigger on systemInfo:USB_DEVICE_ADDED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.usbDeviceAdded(Fixtures.mockRealtekDevice) + const result = makeEvent(action, MOCK_STATE) + + return expect(result).resolves.toEqual(null) + }) + + it('should not trigger on systemInfo:NETWORK_INTERFACES_CHANGED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.networkInterfacesChanged([ + Fixtures.mockNetworkInterface, + ]) + const result = makeEvent(action, MOCK_STATE) + return expect(result).resolves.toEqual(null) + }) }) diff --git a/app/src/analytics/make-event.js b/app/src/analytics/make-event.js index 5448bcbb1b7..ce3a3120449 100644 --- a/app/src/analytics/make-event.js +++ b/app/src/analytics/make-event.js @@ -1,6 +1,5 @@ // @flow // redux action types to analytics events map -import head from 'lodash/head' import { createLogger } from '../logger' import { selectors as robotSelectors } from '../robot' import { getConnectedRobot } from '../discovery' @@ -256,18 +255,24 @@ export function makeEvent( } case SystemInfo.INITIALIZED: - case SystemInfo.USB_DEVICE_ADDED: { - const devices = action.payload.usbDevice - ? [action.payload.usbDevice] - : action.payload.usbDevices - - const superProperties = head( - devices - .filter(SystemInfo.isRealtekU2EAdapter) - .map(SystemInfo.deviceToU2EAnalyticsProps) + case SystemInfo.USB_DEVICE_ADDED: + case SystemInfo.NETWORK_INTERFACES_CHANGED: { + const systemInfoProps = SystemInfo.getU2EDeviceAnalyticsProps(state) + + return Promise.resolve( + systemInfoProps + ? { + superProperties: { + ...systemInfoProps, + // anonymize IP address so analytics profile can't be mapped to more + // specific Intercom support profile + 'U2E IPv4 Address': Boolean( + systemInfoProps['U2E IPv4 Address'] + ), + }, + } + : null ) - - return Promise.resolve(superProperties ? { superProperties } : null) } } diff --git a/app/src/components/SystemInfoCard/U2EAdapterInfo.js b/app/src/components/SystemInfoCard/U2EAdapterInfo.js index f4a9e4b4ebc..0ae945c4b01 100644 --- a/app/src/components/SystemInfoCard/U2EAdapterInfo.js +++ b/app/src/components/SystemInfoCard/U2EAdapterInfo.js @@ -21,6 +21,7 @@ const U2E_ADAPTER_INFORMATION = 'USB-to-Ethernet Adapter Information' export const U2EAdapterInfo = () => { const device = useSelector(SystemInfo.getU2EAdapterDevice) + const ifacesMap = useSelector(SystemInfo.getU2EInterfacesMap) const driverOutdated = useSelector((state: State) => { const status = SystemInfo.getU2EWindowsDriverStatus(state) return status === SystemInfo.OUTDATED @@ -37,7 +38,10 @@ export const U2EAdapterInfo = () => { {U2E_ADAPTER_INFORMATION} {driverOutdated && } - + ) } diff --git a/app/src/components/SystemInfoCard/U2EDeviceDetails.js b/app/src/components/SystemInfoCard/U2EDeviceDetails.js index 3afc3ba0b9c..eb6c5a17335 100644 --- a/app/src/components/SystemInfoCard/U2EDeviceDetails.js +++ b/app/src/components/SystemInfoCard/U2EDeviceDetails.js @@ -11,48 +11,82 @@ import { SPACING_2, } from '@opentrons/components' -import type { UsbDevice } from '../../system-info/types' +import { IFACE_FAMILY_IPV4 } from '../../system-info' +import type { UsbDevice, NetworkInterface } from '../../system-info/types' // TODO(mc, 2020-04-28): i18n const U2E_ADAPTER_DESCRIPTION = "The OT-2 uses a USB-to-Ethernet adapter for its wired connection. When you plug the OT-2 into your computer, this adapter will be added to your computer's device list." const NO_ADAPTER_FOUND = 'No OT-2 USB-to-Ethernet adapter detected' const UNKNOWN = 'unknown' +const NOT_ASSIGNED = 'Not assigned' +const NETWORK_INTERFACE = 'Network Interface' +const IPV4_ADDRESS = 'Local IPv4 Address' +const IPV6_ADDRESS = 'Local IPv6 Address' export type U2EDeviceDetailsProps = {| device: UsbDevice | null, + ifaces: Array, |} const DetailText = styled.span` - min-width: 6rem; + min-width: 8rem; margin-right: ${SPACING_1}; ` -const STATS: Array<{| label: string, property: $Keys |}> = [ +const DEVICE_STATS: Array<{| label: string, property: $Keys |}> = [ { label: 'Description', property: 'deviceName' }, { label: 'Manufacturer', property: 'manufacturer' }, { label: 'Serial Number', property: 'serialNumber' }, { label: 'Driver Version', property: 'windowsDriverVersion' }, ] -export const U2EDeviceDetails = ({ device }: U2EDeviceDetailsProps) => ( -
- {U2E_ADAPTER_DESCRIPTION} - {device === null ? ( - - {NO_ADAPTER_FOUND} - - ) : ( - - {STATS.filter(({ property }) => property in device).map( - ({ label, property }) => ( - - {label}: - {device[property] ?? UNKNOWN} - - ) - )} - - )} -
+const DetailItem = ({ + label, + value, +}: {| + label: string, + value: string | number, +|}) => ( + + {label}: + {value} + ) + +export const U2EDeviceDetails = ({ device, ifaces }: U2EDeviceDetailsProps) => { + const nwIfaceName = ifaces.length > 0 ? ifaces[0].name : NOT_ASSIGNED + + return ( +
+ {U2E_ADAPTER_DESCRIPTION} + {device === null ? ( + + {NO_ADAPTER_FOUND} + + ) : ( + + {DEVICE_STATS.filter(({ property }) => property in device).map( + ({ label, property }) => ( + + ) + )} + + {ifaces.map(iface => ( + + ))} + + )} +
+ ) +} diff --git a/app/src/components/SystemInfoCard/__tests__/U2EAdapterInfo.test.js b/app/src/components/SystemInfoCard/__tests__/U2EAdapterInfo.test.js index 080fd7e5ccd..fcc83697433 100644 --- a/app/src/components/SystemInfoCard/__tests__/U2EAdapterInfo.test.js +++ b/app/src/components/SystemInfoCard/__tests__/U2EAdapterInfo.test.js @@ -10,7 +10,11 @@ import { U2EAdapterInfo } from '../U2EAdapterInfo' import { U2EDriverWarning } from '../U2EDriverWarning' import type { State } from '../../../types' -import type { UsbDevice, DriverStatus } from '../../../system-info/types' +import type { + UsbDevice, + DriverStatus, + U2EInterfaceMap, +} from '../../../system-info/types' jest.mock('../../../system-info/selectors') jest.mock('../../../analytics') @@ -26,6 +30,9 @@ const MOCK_STORE = { const getU2EAdapterDevice: JestMockFn<[State], UsbDevice | null> = SystemInfo.getU2EAdapterDevice +const getU2EInterfacesMap: JestMockFn<[State], U2EInterfaceMap> = + SystemInfo.getU2EInterfacesMap + const getU2EWindowsDriverStatus: JestMockFn<[State], DriverStatus> = SystemInfo.getU2EWindowsDriverStatus @@ -47,6 +54,7 @@ describe('U2EAdapterInfo', () => { beforeEach(() => { stubSelector(getU2EAdapterDevice, null) stubSelector(getU2EWindowsDriverStatus, SystemInfo.NOT_APPLICABLE) + stubSelector(getU2EInterfacesMap, {}) }) afterEach(() => { @@ -123,4 +131,23 @@ describe('U2EAdapterInfo', () => { const wrapper = render() expect(wrapper.exists(U2EDriverWarning)).toBe(true) }) + + it('should display device network adapter information if present', () => { + const device = Fixtures.mockRealtekDevice + const iface4 = Fixtures.mockNetworkInterface + const iface6 = Fixtures.mockNetworkInterfaceV6 + + stubSelector(getU2EAdapterDevice, device) + stubSelector(getU2EInterfacesMap, { + [device.serialNumber]: [iface4, iface6], + }) + + const wrapper = render() + const children = wrapper.children().html() + + expect(getU2EAdapterDevice).toHaveBeenCalledWith(MOCK_STATE) + expect(children).toContain(iface4.name) + expect(children).toContain(iface4.address) + expect(children).toContain(iface6.address) + }) }) diff --git a/app/src/support/__tests__/system-info-profile.test.js b/app/src/support/__tests__/system-info-profile.test.js index 19a0be9afd0..f543e854432 100644 --- a/app/src/support/__tests__/system-info-profile.test.js +++ b/app/src/support/__tests__/system-info-profile.test.js @@ -5,76 +5,87 @@ import { makeProfileUpdate } from '../profile' import * as SystemInfo from '../../system-info' import * as Fixtures from '../../system-info/__fixtures__' -import type { State, Action } from '../../types' -import type { SupportProfileUpdate } from '../types' +import type { State } from '../../types' +import type { U2EAnalyticsProps } from '../../system-info/types' -type EventSpec = {| - should: string, - action: Action, - expected: SupportProfileUpdate | null, -|} +jest.mock('../../system-info/selectors') + +const getU2EDeviceAnalyticsProps: JestMockFn< + [State], + U2EAnalyticsProps | null +> = SystemInfo.getU2EDeviceAnalyticsProps const MOCK_STATE: State = ({ mockState: true }: any) +const MOCK_ANALYTICS_PROPS = { + 'U2E Vendor ID': Fixtures.mockRealtekDevice.vendorId, + 'U2E Product ID': Fixtures.mockRealtekDevice.productId, + 'U2E Serial Number': Fixtures.mockRealtekDevice.serialNumber, + 'U2E Manufacturer': Fixtures.mockRealtekDevice.manufacturer, + 'U2E Device Name': Fixtures.mockRealtekDevice.deviceName, + 'U2E IPv4 Address': '10.0.0.1', +} -const { mockWindowsRealtekDevice, mockRealtekDevice, mockUsbDevice } = Fixtures - -const SPECS: Array = [ - { - should: 'ignore systemInfo:INITIALIZED without Realtek devices', - action: SystemInfo.initialized([mockUsbDevice]), - expected: null, - }, - { - should: 'ignores systemInfo:USB_DEVICE_ADDED without Realtek devices', - action: SystemInfo.usbDeviceAdded(mockUsbDevice), - expected: null, - }, - { - should: 'add Realtek info to super props on systemInfo:INITIALIZED', - action: SystemInfo.initialized([mockRealtekDevice]), - expected: { - 'U2E Vendor ID': mockRealtekDevice.vendorId, - 'U2E Product ID': mockRealtekDevice.productId, - 'U2E Serial Number': mockRealtekDevice.serialNumber, - 'U2E Device Name': mockRealtekDevice.deviceName, - 'U2E Manufacturer': mockRealtekDevice.manufacturer, - }, - }, - { - should: 'add Realtek info to super props on systemInfo:USB_DEVICE_ADDED', - action: SystemInfo.usbDeviceAdded(mockRealtekDevice), - expected: { - 'U2E Vendor ID': mockRealtekDevice.vendorId, - 'U2E Product ID': mockRealtekDevice.productId, - 'U2E Serial Number': mockRealtekDevice.serialNumber, - 'U2E Device Name': mockRealtekDevice.deviceName, - 'U2E Manufacturer': mockRealtekDevice.manufacturer, - }, - }, - { - should: 'include Realtek windows driver version on systemInfo:INITIALIZED', - action: SystemInfo.initialized([mockWindowsRealtekDevice]), - expected: expect.objectContaining({ - 'U2E Windows Driver Version': - mockWindowsRealtekDevice.windowsDriverVersion, - }), - }, - { - should: - 'include Realtek windows driver version on systemInfo:USB_DEVICE_ADDED', - action: SystemInfo.usbDeviceAdded(mockWindowsRealtekDevice), - expected: expect.objectContaining({ - 'U2E Windows Driver Version': - mockWindowsRealtekDevice.windowsDriverVersion, - }), - }, -] - -describe('system-info support profile updates', () => { - SPECS.forEach(spec => { - const { should, action, expected } = spec - it(`should ${should}`, () => { - return expect(makeProfileUpdate(action, MOCK_STATE)).toEqual(expected) +describe('custom labware analytics events', () => { + beforeEach(() => { + getU2EDeviceAnalyticsProps.mockImplementation(state => { + expect(state).toBe(MOCK_STATE) + return MOCK_ANALYTICS_PROPS }) }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should trigger an event on systemInfo:INITIALIZED', () => { + const action = SystemInfo.initialized([Fixtures.mockRealtekDevice], []) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(MOCK_ANALYTICS_PROPS) + }) + + it('should trigger an event on systemInfo:USB_DEVICE_ADDED', () => { + const action = SystemInfo.usbDeviceAdded(Fixtures.mockRealtekDevice) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(MOCK_ANALYTICS_PROPS) + }) + + it('should trigger an event on systemInfo:NETWORK_INTERFACES_CHANGED', () => { + const action = SystemInfo.networkInterfacesChanged([ + Fixtures.mockNetworkInterface, + ]) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(MOCK_ANALYTICS_PROPS) + }) + + it('should not trigger on systemInfo:INITIALIZED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.initialized([Fixtures.mockRealtekDevice], []) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(null) + }) + + it('should not trigger on systemInfo:USB_DEVICE_ADDED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.usbDeviceAdded(Fixtures.mockRealtekDevice) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(null) + }) + + it('should not trigger on systemInfo:NETWORK_INTERFACES_CHANGED if selector returns null', () => { + getU2EDeviceAnalyticsProps.mockReturnValue(null) + + const action = SystemInfo.networkInterfacesChanged([ + Fixtures.mockNetworkInterface, + ]) + const result = makeProfileUpdate(action, MOCK_STATE) + + expect(result).toEqual(null) + }) }) diff --git a/app/src/support/profile.js b/app/src/support/profile.js index 8d2a94cef97..98fa2f68603 100644 --- a/app/src/support/profile.js +++ b/app/src/support/profile.js @@ -1,7 +1,5 @@ // @flow // functions for managing the user's Intercom profile -import head from 'lodash/head' - import { version as appVersion } from '../../package.json' import { FF_PREFIX, getRobotAnalyticsData } from '../analytics' import { getConnectedRobot } from '../discovery' @@ -95,18 +93,9 @@ export function makeProfileUpdate( } case SystemInfo.INITIALIZED: - case SystemInfo.USB_DEVICE_ADDED: { - const devices = action.payload.usbDevice - ? [action.payload.usbDevice] - : action.payload.usbDevices - - const update = head( - devices - .filter(SystemInfo.isRealtekU2EAdapter) - .map(SystemInfo.deviceToU2EAnalyticsProps) - ) - - return update ?? null + case SystemInfo.USB_DEVICE_ADDED: + case SystemInfo.NETWORK_INTERFACES_CHANGED: { + return SystemInfo.getU2EDeviceAnalyticsProps(state) } } return null diff --git a/app/src/system-info/__fixtures__/index.js b/app/src/system-info/__fixtures__/index.js index b3c92ae7597..2b1854782b5 100644 --- a/app/src/system-info/__fixtures__/index.js +++ b/app/src/system-info/__fixtures__/index.js @@ -1,5 +1,5 @@ // @flow -import type { UsbDevice } from '../types' +import type { UsbDevice, NetworkInterface } from '../types' export const mockUsbDevice: UsbDevice = { locationId: 1, @@ -37,3 +37,24 @@ export const mockWindowsRealtekDevice: UsbDevice = { deviceAddress: 5, windowsDriverVersion: '1.2.3', } + +export const mockNetworkInterface: NetworkInterface = { + name: 'en1', + address: '192.168.1.2', + netmask: '255.255.255.0', + family: 'IPv4', + mac: '88:e9:fe:74:69:60', + internal: false, + cidr: '192.168.1.2/24', +} + +export const mockNetworkInterfaceV6: NetworkInterface = { + name: 'en1', + address: 'fe80::87f:5b2:cbc4:1638', + netmask: 'ffff:ffff:ffff:ffff::', + family: 'IPv6', + mac: '88:e9:fe:74:69:60', + internal: false, + cidr: 'fe80::87f:5b2:cbc4:1638/64', + scopeid: 7, +} diff --git a/app/src/system-info/__tests__/actions.test.js b/app/src/system-info/__tests__/actions.test.js index 4c18fc7ee71..70993c36187 100644 --- a/app/src/system-info/__tests__/actions.test.js +++ b/app/src/system-info/__tests__/actions.test.js @@ -32,11 +32,11 @@ const SPECS: Array = [ }, }, { - // TODO(mc, 2020-04-17): add other system info should: 'create a systemInfo:INITIALIZED action', creator: Actions.initialized, args: [ [Fixtures.mockUsbDevice, Fixtures.mockUsbDevice, Fixtures.mockUsbDevice], + [Fixtures.mockNetworkInterface], ], expected: { type: 'systemInfo:INITIALIZED', @@ -46,6 +46,18 @@ const SPECS: Array = [ Fixtures.mockUsbDevice, Fixtures.mockUsbDevice, ], + networkInterfaces: [Fixtures.mockNetworkInterface], + }, + }, + }, + { + should: 'create a systemInfo:NETWORK_INTERFACES_CHANGED action', + creator: Actions.networkInterfacesChanged, + args: [[Fixtures.mockNetworkInterface]], + expected: { + type: 'systemInfo:NETWORK_INTERFACES_CHANGED', + payload: { + networkInterfaces: [Fixtures.mockNetworkInterface], }, }, }, diff --git a/app/src/system-info/__tests__/reducer.test.js b/app/src/system-info/__tests__/reducer.test.js index 79db564f963..1eef14179a4 100644 --- a/app/src/system-info/__tests__/reducer.test.js +++ b/app/src/system-info/__tests__/reducer.test.js @@ -18,21 +18,42 @@ type ReducerSpec = {| const SPECS: Array = [ { should: 'handle systemInfo:INITIALIZED action', - action: Actions.initialized([Fixtures.mockUsbDevice]), - initialState: { usbDevices: [] }, - expectedState: { usbDevices: [Fixtures.mockUsbDevice] }, + action: Actions.initialized( + [Fixtures.mockUsbDevice], + [Fixtures.mockNetworkInterface] + ), + initialState: { usbDevices: [], networkInterfaces: [] }, + expectedState: { + usbDevices: [Fixtures.mockUsbDevice], + networkInterfaces: [Fixtures.mockNetworkInterface], + }, }, { should: 'add single device with systemInfo:USB_DEVICE_ADDED', action: Actions.usbDeviceAdded(Fixtures.mockUsbDevice), - initialState: { usbDevices: [] }, - expectedState: { usbDevices: [Fixtures.mockUsbDevice] }, + initialState: { usbDevices: [], networkInterfaces: [] }, + expectedState: { + usbDevices: [Fixtures.mockUsbDevice], + networkInterfaces: [], + }, }, { should: 'remove device with systemInfo:USB_DEVICE_REMOVED', action: Actions.usbDeviceRemoved(Fixtures.mockUsbDevice), - initialState: { usbDevices: [Fixtures.mockUsbDevice] }, - expectedState: { usbDevices: [] }, + initialState: { + usbDevices: [Fixtures.mockUsbDevice], + networkInterfaces: [], + }, + expectedState: { usbDevices: [], networkInterfaces: [] }, + }, + { + should: 'handle systemInfo:NETWORK_INTERFACES_CHANGED action', + action: Actions.networkInterfacesChanged([Fixtures.mockNetworkInterface]), + initialState: { usbDevices: [], networkInterfaces: [] }, + expectedState: { + usbDevices: [], + networkInterfaces: [Fixtures.mockNetworkInterface], + }, }, ] diff --git a/app/src/system-info/__tests__/selectors.test.js b/app/src/system-info/__tests__/selectors.test.js index 925b0cf0966..ec986016a8a 100644 --- a/app/src/system-info/__tests__/selectors.test.js +++ b/app/src/system-info/__tests__/selectors.test.js @@ -13,7 +13,9 @@ describe('robot controls selectors', () => { }) it('should return null by default with getU2EAdapterDevice', () => { - const state: State = ({ systemInfo: { usbDevices: [] } }: $Shape) + const state: State = ({ + systemInfo: { usbDevices: [], networkInterfaces: [] }, + }: $Shape) expect(Selectors.getU2EAdapterDevice(state)).toBe(null) }) @@ -22,6 +24,7 @@ describe('robot controls selectors', () => { const state: State = ({ systemInfo: { usbDevices: [Fixtures.mockUsbDevice, Fixtures.mockRealtekDevice], + networkInterfaces: [], }, }: $Shape) @@ -35,6 +38,7 @@ describe('robot controls selectors', () => { const state: State = ({ systemInfo: { usbDevices: [Fixtures.mockUsbDevice, Fixtures.mockRealtekDevice], + networkInterfaces: [], }, }: $Shape) @@ -58,6 +62,7 @@ describe('robot controls selectors', () => { Fixtures.mockUsbDevice, Fixtures.mockWindowsRealtekDevice, ], + networkInterfaces: [], }, }: $Shape) @@ -66,4 +71,163 @@ describe('robot controls selectors', () => { ) }) }) + + describe('getU2EInterfacesMap', () => { + it('should return empty dict by default', () => { + const state: State = ({ + systemInfo: { usbDevices: [], networkInterfaces: [] }, + }: $Shape) + + expect(Selectors.getU2EInterfacesMap(state)).toEqual({}) + }) + + it('should return empty iface array if adapter found but no interface with same MAC', () => { + const state: State = ({ + systemInfo: { + usbDevices: [Fixtures.mockRealtekDevice], + networkInterfaces: [Fixtures.mockNetworkInterface], + }, + }: $Shape) + + expect(Selectors.getU2EInterfacesMap(state)).toEqual({ + [Fixtures.mockRealtekDevice.serialNumber]: [], + }) + }) + + it('should return interface with matching MAC', () => { + const mac = ['01', '23', '45', '67', '89', 'AB'] + const adapter = { + ...Fixtures.mockRealtekDevice, + serialNumber: mac.join(''), + } + const iface = { + ...Fixtures.mockNetworkInterface, + mac: mac.join(':').toLowerCase(), + } + + const state: State = ({ + systemInfo: { usbDevices: [adapter], networkInterfaces: [iface] }, + }: $Shape) + + expect(Selectors.getU2EInterfacesMap(state)).toEqual({ + [adapter.serialNumber]: [iface], + }) + }) + + it('should handle multiple devices and interface with matching MAC', () => { + const mac1 = ['01', '23', '45', '67', '89', 'AB'] + const mac2 = ['FE', 'DC', 'BA', '98', '76', '54'] + + const adapter1 = { + ...Fixtures.mockRealtekDevice, + serialNumber: mac1.join(''), + } + const adapter2 = { + ...Fixtures.mockRealtekDevice, + serialNumber: mac2.join(''), + } + + const iface1v4 = { + ...Fixtures.mockNetworkInterface, + mac: mac1.join(':').toLowerCase(), + } + const iface1v6 = { + ...Fixtures.mockNetworkInterfaceV6, + mac: mac1.join(':'), + } + const iface2v4 = { + ...Fixtures.mockNetworkInterface, + mac: mac2.join(':').toLowerCase(), + } + + const state: State = ({ + systemInfo: { + usbDevices: [adapter1, adapter2], + networkInterfaces: [ + Fixtures.mockNetworkInterface, + iface1v4, + iface1v6, + iface2v4, + ], + }, + }: $Shape) + + expect(Selectors.getU2EInterfacesMap(state)).toEqual({ + [adapter1.serialNumber]: [iface1v4, iface1v6], + [adapter2.serialNumber]: [iface2v4], + }) + }) + }) + + describe('getU2EDeviceAnalyticsProps', () => { + it('should return null if no Realtek device', () => { + const state: State = ({ + systemInfo: { + usbDevices: [Fixtures.mockUsbDevice], + networkInterfaces: [], + }, + }: $Shape) + + expect(Selectors.getU2EDeviceAnalyticsProps(state)).toBe(null) + }) + + it('should return device props if Realtek device', () => { + const state: State = ({ + systemInfo: { + usbDevices: [Fixtures.mockRealtekDevice], + networkInterfaces: [], + }, + }: $Shape) + + expect(Selectors.getU2EDeviceAnalyticsProps(state)).toEqual({ + 'U2E Vendor ID': Fixtures.mockRealtekDevice.vendorId, + 'U2E Product ID': Fixtures.mockRealtekDevice.productId, + 'U2E Serial Number': Fixtures.mockRealtekDevice.serialNumber, + 'U2E Device Name': Fixtures.mockRealtekDevice.deviceName, + 'U2E Manufacturer': Fixtures.mockRealtekDevice.manufacturer, + 'U2E IPv4 Address': null, + }) + }) + + it('should include Windows driver version if applicable', () => { + const state: State = ({ + systemInfo: { + usbDevices: [Fixtures.mockWindowsRealtekDevice], + networkInterfaces: [], + }, + }: $Shape) + + expect(Selectors.getU2EDeviceAnalyticsProps(state)).toMatchObject({ + 'U2E Windows Driver Version': + Fixtures.mockWindowsRealtekDevice.windowsDriverVersion, + }) + }) + + it('should include IPv4 address if available', () => { + const mac = ['01', '23', '45', '67', '89', 'AB'] + const adapter = { + ...Fixtures.mockRealtekDevice, + serialNumber: mac.join(''), + } + const ifaceV4 = { + ...Fixtures.mockNetworkInterface, + mac: mac.join(':').toLowerCase(), + } + const ifaceV6 = { + ...Fixtures.mockNetworkInterfaceV6, + mac: mac.join(':').toLowerCase(), + } + + const state: State = ({ + systemInfo: { + usbDevices: [adapter], + networkInterfaces: [ifaceV6, ifaceV4], + }, + }: $Shape) + + expect(Selectors.getU2EDeviceAnalyticsProps(state)).toMatchObject({ + 'U2E IPv4 Address': ifaceV4.address, + }) + }) + }) }) diff --git a/app/src/system-info/actions.js b/app/src/system-info/actions.js index 3f38b35bdf3..18ae97e7bef 100644 --- a/app/src/system-info/actions.js +++ b/app/src/system-info/actions.js @@ -4,12 +4,12 @@ import * as Constants from './constants' import * as Types from './types' -// TODO(mc, 2020-04-17): add other system info export const initialized = ( - usbDevices: Array + usbDevices: Array, + networkInterfaces: Array ): Types.InitializedAction => ({ type: Constants.INITIALIZED, - payload: { usbDevices }, + payload: { usbDevices, networkInterfaces }, }) export const usbDeviceAdded = ( @@ -25,3 +25,10 @@ export const usbDeviceRemoved = ( type: Constants.USB_DEVICE_REMOVED, payload: { usbDevice }, }) + +export const networkInterfacesChanged = ( + networkInterfaces: Array +): Types.NetworkInterfacesChangedAction => ({ + type: Constants.NETWORK_INTERFACES_CHANGED, + payload: { networkInterfaces }, +}) diff --git a/app/src/system-info/constants.js b/app/src/system-info/constants.js index 126deedeedd..33d0c8cd856 100644 --- a/app/src/system-info/constants.js +++ b/app/src/system-info/constants.js @@ -12,6 +12,10 @@ export const UNKNOWN: 'UNKNOWN' = 'UNKNOWN' export const UP_TO_DATE: 'UP_TO_DATE' = 'UP_TO_DATE' export const OUTDATED: 'OUTDATED' = 'OUTDATED' +// network interface families +export const IFACE_FAMILY_IPV4 = 'IPv4' +export const IFACE_FAMILY_IPV6 = 'IPv6' + // action types export const INITIALIZED: 'systemInfo:INITIALIZED' = 'systemInfo:INITIALIZED' @@ -22,6 +26,9 @@ export const USB_DEVICE_ADDED: 'systemInfo:USB_DEVICE_ADDED' = export const USB_DEVICE_REMOVED: 'systemInfo:USB_DEVICE_REMOVED' = 'systemInfo:USB_DEVICE_REMOVED' +export const NETWORK_INTERFACES_CHANGED: 'systemInfo:NETWORK_INTERFACES_CHANGED' = + 'systemInfo:NETWORK_INTERFACES_CHANGED' + // analytics events export const EVENT_U2E_DRIVER_ALERT_DISMISSED = 'u2eDriverAlertDismissed' diff --git a/app/src/system-info/reducer.js b/app/src/system-info/reducer.js index fad2f096e07..c424b9fcf6c 100644 --- a/app/src/system-info/reducer.js +++ b/app/src/system-info/reducer.js @@ -8,6 +8,7 @@ import type { SystemInfoState } from './types' const INITIAL_STATE: SystemInfoState = { usbDevices: [], + networkInterfaces: [], } export function systemInfoReducer( @@ -16,7 +17,8 @@ export function systemInfoReducer( ): SystemInfoState { switch (action.type) { case Constants.INITIALIZED: { - return { ...state, usbDevices: action.payload.usbDevices } + const { usbDevices, networkInterfaces } = action.payload + return { ...state, usbDevices, networkInterfaces } } case Constants.USB_DEVICE_ADDED: { @@ -39,6 +41,10 @@ export function systemInfoReducer( }), } } + + case Constants.NETWORK_INTERFACES_CHANGED: { + return { ...state, networkInterfaces: action.payload.networkInterfaces } + } } return state diff --git a/app/src/system-info/selectors.js b/app/src/system-info/selectors.js index b9659d58b4d..167432b8f7e 100644 --- a/app/src/system-info/selectors.js +++ b/app/src/system-info/selectors.js @@ -1,10 +1,15 @@ // @flow import { createSelector } from 'reselect' import { isRealtekU2EAdapter, getDriverStatus } from './utils' -import { NOT_APPLICABLE } from './constants' +import { NOT_APPLICABLE, IFACE_FAMILY_IPV4 } from './constants' import type { State } from '../types' -import type { UsbDevice, DriverStatus } from './types' +import type { + UsbDevice, + DriverStatus, + U2EInterfaceMap, + U2EAnalyticsProps, +} from './types' export const getU2EAdapterDevice: ( state: State @@ -19,3 +24,55 @@ export const getU2EWindowsDriverStatus: ( getU2EAdapterDevice, device => (device !== null ? getDriverStatus(device) : NOT_APPLICABLE) ) + +export const getU2EInterfacesMap: ( + state: State +) => U2EInterfaceMap = createSelector( + state => state.systemInfo.usbDevices, + state => state.systemInfo.networkInterfaces, + (usbDevices, networkInterfaces) => { + const ue2Adapters = usbDevices.filter(isRealtekU2EAdapter) + + return ue2Adapters.reduce((interfacesBySerial, device) => { + interfacesBySerial[device.serialNumber] = networkInterfaces.filter( + iface => { + const expectedSerial = iface.mac + .split(':') + .join('') + .toLowerCase() + + return device.serialNumber.toLowerCase() === expectedSerial + } + ) + return interfacesBySerial + }, {}) + } +) + +export const getU2EDeviceAnalyticsProps: ( + state: State +) => U2EAnalyticsProps | null = createSelector( + getU2EAdapterDevice, + getU2EInterfacesMap, + (device, ifacesMap) => { + if (!device) return null + const ifaces = ifacesMap[device.serialNumber] + const ip = + ifaces.find(iface => iface.family === IFACE_FAMILY_IPV4)?.address ?? null + + const result: U2EAnalyticsProps = { + 'U2E Vendor ID': device.vendorId, + 'U2E Product ID': device.productId, + 'U2E Serial Number': device.serialNumber, + 'U2E Manufacturer': device.manufacturer, + 'U2E Device Name': device.deviceName, + 'U2E IPv4 Address': ip, + } + + if (device.windowsDriverVersion) { + result['U2E Windows Driver Version'] = device.windowsDriverVersion + } + + return result + } +) diff --git a/app/src/system-info/types.js b/app/src/system-info/types.js index 6ab51aa7441..d31de795a1b 100644 --- a/app/src/system-info/types.js +++ b/app/src/system-info/types.js @@ -5,6 +5,7 @@ import typeof { INITIALIZED, USB_DEVICE_ADDED, USB_DEVICE_REMOVED, + NETWORK_INTERFACES_CHANGED, NOT_APPLICABLE, UNKNOWN, UP_TO_DATE, @@ -22,6 +23,23 @@ export type UsbDevice = {| windowsDriverVersion?: string | null, |} +// based on built-in type os$NetIFAddr +export type NetworkInterface = {| + name: string, + address: string, + netmask: string, + family: string, + mac: string, + internal: boolean, + cidr: string, + scopeid?: number, +|} + +export type U2EInterfaceMap = { + [deviceSerialNumber: string]: Array, + ..., +} + export type DriverStatus = NOT_APPLICABLE | UNKNOWN | UP_TO_DATE | OUTDATED export type U2EAnalyticsProps = {| @@ -30,18 +48,23 @@ export type U2EAnalyticsProps = {| 'U2E Serial Number': string, 'U2E Device Name': string, 'U2E Manufacturer': string, + 'U2E IPv4 Address': string | null, 'U2E Windows Driver Version'?: string | null, |} // TODO(mc, 2020-04-17): add other system info export type SystemInfoState = {| usbDevices: Array, + networkInterfaces: Array, |} // TODO(mc, 2020-04-17): add other system info export type InitializedAction = {| type: INITIALIZED, - payload: {| usbDevices: Array |}, + payload: {| + usbDevices: Array, + networkInterfaces: Array, + |}, |} export type UsbDeviceAddedAction = {| @@ -54,7 +77,13 @@ export type UsbDeviceRemovedAction = {| payload: {| usbDevice: UsbDevice |}, |} +export type NetworkInterfacesChangedAction = {| + type: NETWORK_INTERFACES_CHANGED, + payload: {| networkInterfaces: Array |}, +|} + export type SystemInfoAction = | InitializedAction | UsbDeviceAddedAction | UsbDeviceRemovedAction + | NetworkInterfacesChangedAction diff --git a/app/src/system-info/utils.js b/app/src/system-info/utils.js index a1fe4aff4b2..2a97fb9d373 100644 --- a/app/src/system-info/utils.js +++ b/app/src/system-info/utils.js @@ -3,7 +3,7 @@ import { NOT_APPLICABLE, UNKNOWN, UP_TO_DATE, OUTDATED } from './constants' -import type { UsbDevice, U2EAnalyticsProps, DriverStatus } from './types' +import type { UsbDevice, DriverStatus } from './types' // NOTE(mc, 2020-05-05): this will cause false alerts on Windows 7; Realtek's // versioning scheme seems to be WindowsVersion.Something.Something.Something @@ -21,24 +21,6 @@ const REALTEK_UP_TO_DATE_VERSION = [10, 38] const REALTEK_VID = parseInt('0BDA', 16) const RE_REALTEK_PID = /^8[0|1]5[0-9]$/ -export const deviceToU2EAnalyticsProps = ( - device: UsbDevice -): U2EAnalyticsProps => { - const result: U2EAnalyticsProps = { - 'U2E Vendor ID': device.vendorId, - 'U2E Product ID': device.productId, - 'U2E Serial Number': device.serialNumber, - 'U2E Manufacturer': device.manufacturer, - 'U2E Device Name': device.deviceName, - } - - if (device.windowsDriverVersion) { - result['U2E Windows Driver Version'] = device.windowsDriverVersion - } - - return result -} - export const isRealtekU2EAdapter = (device: UsbDevice): boolean => { return ( device.vendorId === REALTEK_VID &&