import React, {useState, useEffect, FunctionComponent} from 'react' import {InfluxDB, flux, Point} from '@influxdata/influxdb-client-browser' import {Tooltip, Button, Progress, notification} from 'antd' import {RouteComponentProps} from 'react-router-dom' import PageContent, {Message} from './PageContent' import { generateTemperature, generateHumidity, generatePressure, generateCO2, generateTVOC, generateGPXData, } from '../util/generateValue' import {InfoCircleFilled, PlayCircleOutlined} from '@ant-design/icons' import Table, {ColumnsType} from 'antd/lib/table' import {COLOR_LINK} from '../styles/colors' import {Title} from '../util/Antd.utils' import {GridDescription} from '../util/GridDescription' import {IconDashboard, IconRefresh, IconWriteData} from '../styles/icons' import RealTimeSettings from '../util/realtime/RealTimeSettings' import {InputConfirm} from '../util/InputConfirm' interface DeviceConfig { influx_url: string influx_org: string influx_token: string influx_bucket: string id: string device?: string default_lat?: number default_lon?: number write_endpoint?: string createdAt: string mqtt_topic?: string mqtt_url?: string } interface measurementSummaryRow { _field: string minValue: number maxValue: number maxTime: string count: string sensor: string } interface DeviceData { config: DeviceConfig measurements: measurementSummaryRow[] } type ProgressFn = (percent: number, current: number, total: number) => void const VIRTUAL_DEVICE = 'virtual_device' async function fetchDeviceConfig(deviceId: string): Promise<DeviceConfig> { const response = await fetch( `/api/env/${deviceId}?register=${deviceId === VIRTUAL_DEVICE}` ) if (response.status >= 300) { const text = await response.text() throw new Error(`${response.status} ${text}`) } const deviceConfig: DeviceConfig = await response.json() if (!deviceConfig.influx_token) { throw new Error(`Device '${deviceId}' is not authorized!`) } return deviceConfig } async function fetchDeviceData(config: DeviceConfig): Promise<DeviceData> { const { // influx_url: url, // use '/influx' proxy to avoid problem with InfluxDB v2 Beta (Docker) influx_token: token, influx_org: org, influx_bucket: bucket, id, } = config const influxDB = new InfluxDB({url: '/influx', token}) const queryApi = influxDB.getQueryApi(org) const measurementsYieldName = 'measurements' const sensorsYieldName = 'sensors' const result = await queryApi.collectRows<any>(flux` deviceData = from(bucket: ${bucket}) |> range(start: -30d) |> filter(fn: (r) => r._measurement == "environment") |> filter(fn: (r) => r.clientId == ${id}) |> filter(fn: (r) => r._field != "s2_cell_id") measurements = deviceData |> keep(columns: ["_field", "_value", "_time"]) |> group(columns: ["_field"]) counts = measurements |> count() |> keep(columns: ["_field", "_value"]) |> rename(columns: {_value: "count" }) maxValues = measurements |> max () |> toFloat() |> keep(columns: ["_field", "_value"]) |> rename(columns: {_value: "maxValue"}) minValues = measurements |> min () |> toFloat() |> keep(columns: ["_field", "_value"]) |> rename(columns: {_value: "minValue"}) maxTimes = measurements |> max (column: "_time") |> keep(columns: ["_field", "_time" ]) |> rename(columns: {_time : "maxTime" }) j = (tables=<-, t) => join(tables: {tables, t}, on:["_field"]) counts |> j(t: maxValues) |> j(t: minValues) |> j(t: maxTimes) |> yield(name: ${measurementsYieldName}) deviceData |> last() |> keep(columns: [ "TemperatureSensor", "HumiditySensor", "PressureSensor", "CO2Sensor", "TVOCSensor", "GPSSensor" ]) |> yield(name: ${sensorsYieldName}) `) const measurements: measurementSummaryRow[] = result.filter( (x) => x.result === measurementsYieldName ) const sensors: {[key: string]: string} = result.find((x) => x.result === sensorsYieldName) ?? {} measurements.forEach((x) => { const {_field} = x const senosorTagName = (_field === 'Lat' || _field === 'Lon' ? 'GPS' : _field) + 'Sensor' x.sensor = sensors[senosorTagName] ?? '' }) return {config, measurements} } async function fetchDeviceMissingDataTimeStamps( config: DeviceConfig ): Promise<number[]> { const { // influx_url: url, // use '/influx' proxy to avoid problem with InfluxDB v2 Beta (Docker) influx_token: token, influx_org: org, influx_bucket: bucket, id, } = config const influxDB = new InfluxDB({url: '/influx', token}) const queryApi = influxDB.getQueryApi(org) const result = await queryApi.collectRows<any>(flux` from(bucket: ${bucket}) // stop is set to give telegraf some time to flush points from mqtt // so we don't write one point two times |> range(start: -7d, stop: -1m) |> filter(fn: (r) => r["_measurement"] == "environment") |> filter(fn: (r) => r.clientId == ${id}) |> filter(fn: (r) => r["_field"] == "Temperature") |> aggregateWindow(every: 1m, fn: count, createEmpty: true) |> filter(fn: (r) => r._value == 0) |> keep(columns: ["_time"]) |> rename(columns: {_time: "_value"}) |> toInt() // transform from ns to ms |> map(fn: (r)=> ({_value: r._value/1000000})) `) return result.map((x) => x._value).slice(1) } const fetchGPXData = async (): Promise<[number, number][]> => await (await fetch('/api/gpxVirtual')).json() const fetchSetDeviceType = async (deviceId: string, deviceType: string) => { const setTypeResponse = await fetch( `/api/devices/${deviceId}/type/${deviceType}`, { method: 'POST', } ) if (setTypeResponse.status >= 300) { const text = await setTypeResponse.text() throw new Error(`${setTypeResponse.status} ${text}`) } } async function writeEmulatedData( state: DeviceData, onProgress: ProgressFn, missingDataTimeStamps?: number[] ): Promise<number> { const { // influx_url: url, // use '/influx' proxy to avoid problems with InfluxDB v2 Beta (Docker) influx_token: token, influx_org: org, influx_bucket: bucket, write_endpoint, id, } = state.config // calculate window to emulate writes const toTime = Math.trunc(Date.now() / 60_000) * 60_000 let lastTime = state.measurements[0]?.maxTime ? Math.trunc(Date.parse(state.measurements[0].maxTime) / 60_000) * 60_000 : 0 if (lastTime < toTime - 7 * 24 * 60 * 60 * 1000) { lastTime = toTime - 7 * 24 * 60 * 60 * 1000 } const getGPX = generateGPXData.bind(undefined, await fetchGPXData()) const totalPoints = missingDataTimeStamps?.length || Math.trunc((toTime - lastTime) / 60_000) let pointsWritten = 0 if (totalPoints > 0) { const batchSize = 2000 const url = write_endpoint && write_endpoint !== '/mqtt' ? write_endpoint : '/influx' const influxDB = new InfluxDB({url, token}) const writeApi = influxDB.getWriteApi(org, bucket, 'ns', { batchSize: batchSize + 1, defaultTags: {clientId: id}, }) try { // write random temperatures const point = new Point('environment') // reuse the same point to spare memory onProgress(0, 0, totalPoints) const writePoint = async (time: number) => { const gpx = getGPX(time) point .floatField('Temperature', generateTemperature(time)) .floatField('Humidity', generateHumidity(time)) .floatField('Pressure', generatePressure(time)) .intField('CO2', generateCO2(time)) .intField('TVOC', generateTVOC(time)) .floatField('Lat', gpx[0] || state.config.default_lat || 50.0873254) .floatField('Lon', gpx[1] || state.config.default_lon || 14.4071543) .tag('TemperatureSensor', 'virtual_TemperatureSensor') .tag('HumiditySensor', 'virtual_HumiditySensor') .tag('PressureSensor', 'virtual_PressureSensor') .tag('CO2Sensor', 'virtual_CO2Sensor') .tag('TVOCSensor', 'virtual_TVOCSensor') .tag('GPSSensor', 'virtual_GPSSensor') .tag('Device', 'virtual') .timestamp(String(time) + '000000') writeApi.writePoint(point) pointsWritten++ if (pointsWritten % batchSize === 0) { await writeApi.flush() onProgress( (pointsWritten / totalPoints) * 100, pointsWritten, totalPoints ) } } if (missingDataTimeStamps?.length) for (const timestamp of missingDataTimeStamps) await writePoint(timestamp) else while (lastTime < toTime) { lastTime += 60_000 // emulate next minute await writePoint(lastTime) } await writeApi.flush() } finally { await writeApi.close() } onProgress(100, pointsWritten, totalPoints) } return pointsWritten } const measurementTableRowKey = (r: any) => r._field interface PropsRoute { deviceId?: string } interface Props { helpCollapsed: boolean mqttEnabled: boolean | undefined } const DevicePage: FunctionComponent< RouteComponentProps<PropsRoute> & Props > = ({match, location, helpCollapsed, mqttEnabled}) => { const deviceId = match.params.deviceId ?? VIRTUAL_DEVICE const [loading, setLoading] = useState(true) const [message, setMessage] = useState<Message | undefined>() const [deviceData, setDeviceData] = useState<DeviceData | undefined>() const [dataStamp, setDataStamp] = useState(0) const [progress, setProgress] = useState(-1) const writeAllowed = deviceId === VIRTUAL_DEVICE || new URLSearchParams(location.search).get('write') === 'true' const isVirtualDevice = deviceId === VIRTUAL_DEVICE // fetch device configuration and data useEffect(() => { const fetchData = async () => { setLoading(true) try { const deviceConfig = await fetchDeviceConfig(deviceId) setDeviceData(await fetchDeviceData(deviceConfig)) } catch (e) { console.error(e) setMessage({ title: 'Cannot load device data', description: String(e), type: 'error', }) } finally { setLoading(false) } } fetchData() }, [dataStamp, deviceId]) async function writeData() { const onProgress: ProgressFn = (percent /*, current, total */) => { // console.log( // `writeData ${current}/${total} (${Math.trunc(percent * 100) / 100}%)` // ); setProgress(percent) } try { if (!deviceData) return const missingDataTimeStamps = mqttEnabled ? await fetchDeviceMissingDataTimeStamps(deviceData.config) : undefined const count = await writeEmulatedData( deviceData, onProgress, missingDataTimeStamps ) if (count) { notification.success({ message: ( <> <b>{count}</b> measurement point{count > 1 ? 's were' : ' was'}{' '} written to InfluxDB. </> ), }) setDataStamp(dataStamp + 1) // reload device data } else { notification.info({ message: `No new data were written to InfluxDB, the current measurement is already written.`, }) } } catch (e) { console.error(e) setMessage({ title: 'Cannot write data', description: String(e), type: 'error', }) } finally { setProgress(-1) } } const writeButtonDisabled = progress !== -1 || loading const pageControls = ( <> {writeAllowed ? ( <Tooltip title="Write Missing Data for the last 7 days" placement="top"> <Button type="primary" onClick={writeData} disabled={writeButtonDisabled} icon={<IconWriteData />} /> </Tooltip> ) : undefined} <Tooltip title="Reload" placement="topRight"> <Button disabled={loading} loading={loading} onClick={() => setDataStamp(dataStamp + 1)} icon={<IconRefresh />} /> </Tooltip> <Tooltip title="Go to device realtime dashboard" placement="topRight"> <Button type={mqttEnabled ? 'default' : 'ghost'} icon={<PlayCircleOutlined />} href={`/realtime/${deviceId}`} ></Button> </Tooltip> <Tooltip title="Go to device dashboard" placement="topRight"> <Button icon={<IconDashboard />} href={`/dashboard/${deviceId}`} ></Button> </Tooltip> </> ) const columnDefinitions: ColumnsType<measurementSummaryRow> = [ { title: 'Field', dataIndex: '_field', }, { title: 'min', dataIndex: 'minValue', render: (val: number) => +val.toFixed(2), align: 'right', }, { title: 'max', dataIndex: 'maxValue', render: (val: number) => +val.toFixed(2), align: 'right', }, { title: 'max time', dataIndex: 'maxTime', }, { title: 'entry count', dataIndex: 'count', align: 'right', }, { title: 'sensor', dataIndex: 'sensor', }, ] return ( <PageContent title={ isVirtualDevice ? ( <> {'Virtual Device'} <Tooltip title="This page writes temperature measurements for the last 7 days from an emulated device, the temperature is reported every minute."> <InfoCircleFilled style={{fontSize: '1em', marginLeft: 5}} /> </Tooltip> </> ) : ( `Device ${deviceId}` ) } message={message} spin={loading} titleExtra={pageControls} > {deviceId === VIRTUAL_DEVICE ? ( <> <div style={{visibility: progress >= 0 ? 'visible' : 'hidden'}}> <Progress percent={progress >= 0 ? Math.trunc(progress) : 0} strokeColor={COLOR_LINK} /> </div> </> ) : undefined} <GridDescription title="Device Configuration" column={ helpCollapsed ? {xxl: 3, xl: 2, md: 1, sm: 1} : {xxl: 2, md: 1, sm: 1} } descriptions={[ { label: 'Device ID', value: deviceData?.config.id, }, { label: 'Registration Time', value: deviceData?.config.createdAt, }, { label: 'InfluxDB URL', value: deviceData?.config.influx_url, }, { label: 'InfluxDB Organization', value: deviceData?.config.influx_org, }, { label: 'InfluxDB Bucket', value: deviceData?.config.influx_bucket, }, { label: 'InfluxDB Token', value: deviceData?.config.influx_token ? '***' : 'N/A', }, ...(mqttEnabled ? [ { label: 'Mqtt URL', value: deviceData?.config?.mqtt_url, }, { label: 'Mqtt topic', value: deviceData?.config?.mqtt_topic, }, ] : []), { label: 'Device type', value: ( <InputConfirm value={deviceData?.config?.device} tooltip={'Device type is used for dynamic dashboard filtering'} onValueChange={async (newValue) => { try { await fetchSetDeviceType(deviceId, newValue) setDataStamp(dataStamp + 1) } catch (e) { console.error(e) setMessage({ title: 'Cannot load device data', description: String(e), type: 'error', }) } }} /> ), }, ]} /> <Title>Measurements</Title> <Table dataSource={deviceData?.measurements} columns={columnDefinitions} pagination={false} rowKey={measurementTableRowKey} /> <div style={{height: 20}} /> {isVirtualDevice && mqttEnabled ? ( <RealTimeSettings onBeforeStart={writeData} /> ) : undefined} </PageContent> ) } export default DevicePage