UNPKG

node-red-contrib-datacake-helpers

Version:

Node-RED nodes to interact with the Datacake GraphQL API for KPIs and device stats. Includes other helper nodes for device management.

335 lines (284 loc) 13.1 kB
const axios = require('axios'); module.exports = function(RED) { function DatacakeGraphQLProductStatsNode(config) { RED.nodes.createNode(this, config); const node = this; // Get configuration node this.datacakeConfig = RED.nodes.getNode(config.datacakeConfig); this.productId = config.productId; this.selectedFields = config.selectedFields || []; this.searchTags = config.searchTags || []; this.searchTagsAnyAll = config.searchTagsAnyAll || "any"; // Store available products and fields from the config this.availableProducts = config.availableProducts || []; this.availableFields = config.availableFields || []; this.availableTags = config.availableTags || []; if (!this.datacakeConfig) { this.status({ fill: "red", shape: "ring", text: "Missing configuration" }); return; } this.status({ fill: "green", shape: "dot", text: "Ready" }); // Function to fetch device data for a product from Datacake const fetchProductDevicesData = async () => { if (!node.productId || !node.selectedFields || node.selectedFields.length === 0) { node.error("Product ID or fields not configured"); node.status({ fill: "red", shape: "ring", text: "Configuration incomplete" }); return null; } try { // Format the field names for the query const fieldNamesStr = JSON.stringify(node.selectedFields.map(f => f.fieldName)); const searchTagsStr = JSON.stringify(node.searchTags); const response = await axios({ url: 'https://api.datacake.co/graphql/', method: 'post', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${node.datacakeConfig.credentials.workspaceToken}` }, data: { query: ` query { allDevices(isProduct:"${node.productId}", searchTags:${searchTagsStr}, searchTagsAnyAll:${node.searchTagsAnyAll}) { id verboseName online lastHeard lastHeardThreshold currentMeasurements(fieldNames:${fieldNamesStr}) { field { fieldName } value } } } ` } }); if (response.data.errors) { throw new Error(response.data.errors[0].message); } return response.data; } catch (error) { node.error(`Error fetching device data: ${error.message}`); node.status({ fill: "red", shape: "ring", text: `Error: ${error.message}` }); return null; } }; // Process the product devices data to calculate statistics const calculateStatistics = (data) => { if (!data || !data.data || !data.data.allDevices) return null; const devices = data.data.allDevices; if (!devices.length) return { error: "No devices found for this product" }; // Object to hold stats for each field const stats = {}; // Fleet health KPIs stats.fleetHealth = { totalDevices: devices.length, onlineDevices: 0, offlineDevices: 0 }; // Initialize stats object for each selected field node.selectedFields.forEach(field => { stats[field.fieldName] = { min: Number.MAX_VALUE, max: Number.MIN_VALUE, sum: 0, count: 0, avg: 0, minDevice: null, maxDevice: null, values: [] // Store all values for debugging }; }); // Process each device devices.forEach(device => { // Update fleet health stats if (device.online) { stats.fleetHealth.onlineDevices++; } else { stats.fleetHealth.offlineDevices++; } if (!device.currentMeasurements) return; device.currentMeasurements.forEach(measurement => { const fieldName = measurement.field.fieldName; const value = parseFloat(measurement.value); // Skip if the value is not a number if (isNaN(value)) return; const fieldStats = stats[fieldName]; if (!fieldStats) return; // Skip if field wasn't selected // Update statistics fieldStats.values.push({ deviceId: device.id, deviceName: device.verboseName, value: value }); fieldStats.sum += value; fieldStats.count++; // Update min if this value is lower if (value < fieldStats.min) { fieldStats.min = value; fieldStats.minDevice = { id: device.id, name: device.verboseName }; } // Update max if this value is higher if (value > fieldStats.max) { fieldStats.max = value; fieldStats.maxDevice = { id: device.id, name: device.verboseName }; } }); }); // Calculate average for each field Object.keys(stats).forEach(fieldName => { const fieldStats = stats[fieldName]; if (fieldStats.count > 0) { fieldStats.avg = fieldStats.sum / fieldStats.count; } else { // No valid values for this field fieldStats.min = null; fieldStats.max = null; } }); // Flatten the structure for Datacake compatibility const flattenedStats = {}; // Flatten fleet health KPIs flattenedStats.fleet_health_total_devices = stats.fleetHealth.totalDevices; flattenedStats.fleet_health_online_devices = stats.fleetHealth.onlineDevices; flattenedStats.fleet_health_offline_devices = stats.fleetHealth.offlineDevices; // Flatten field statistics node.selectedFields.forEach(field => { const fieldName = field.fieldName; const fieldStats = stats[fieldName]; if (fieldStats) { // Add basic statistics flattenedStats[`${fieldName}_min`] = fieldStats.min === Number.MAX_VALUE ? null : fieldStats.min; flattenedStats[`${fieldName}_max`] = fieldStats.max === Number.MIN_VALUE ? null : fieldStats.max; flattenedStats[`${fieldName}_avg`] = fieldStats.avg; flattenedStats[`${fieldName}_sum`] = fieldStats.sum; flattenedStats[`${fieldName}_count`] = fieldStats.count; // Add device references flattenedStats[`${fieldName}_min_device`] = fieldStats.minDevice; flattenedStats[`${fieldName}_max_device`] = fieldStats.maxDevice; // Add values array flattenedStats[`${fieldName}_values`] = fieldStats.values; } }); return flattenedStats; }; // Listen for incoming messages node.on('input', async function(msg, send, done) { node.status({ fill: "blue", shape: "dot", text: "Fetching data..." }); const data = await fetchProductDevicesData(); if (data) { const stats = calculateStatistics(data); if (stats) { msg.payload = stats; node.status({ fill: "green", shape: "dot", text: "Stats calculated" }); send(msg); } else { node.status({ fill: "yellow", shape: "ring", text: "No valid data" }); } } if (done) { done(); } }); node.on('close', function() { // Clean up }); } // Function to dynamically fetch products list for the editor RED.httpAdmin.get('/datacakegraphql/products', RED.auth.needsPermission('datacakegraphql.read'), async function(req, res) { if (!req.query.configId) { res.status(400).json({ error: "No config node ID provided" }); return; } const configNode = RED.nodes.getNode(req.query.configId); if (!configNode) { res.status(400).json({ error: "Config node not found" }); return; } try { const response = await axios({ url: 'https://api.datacake.co/graphql/', method: 'post', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${configNode.credentials.workspaceToken}` }, data: { query: ` query { allProducts { id name measurementFields(active:true) { id fieldName fieldType } } } ` } }); if (response.data.errors) { throw new Error(response.data.errors[0].message); } res.status(200).json(response.data); } catch (error) { res.status(500).json({ error: error.message }); } }); // Function to dynamically fetch tags for the editor RED.httpAdmin.get('/datacakegraphql/tags', RED.auth.needsPermission('datacakegraphql.read'), async function(req, res) { if (!req.query.configId) { res.status(400).json({ error: "No config node ID provided" }); return; } const configNode = RED.nodes.getNode(req.query.configId); if (!configNode) { res.status(400).json({ error: "Config node not found" }); return; } try { // Get workspace ID from configNode const workspaceUuid = configNode.workspaceUuid; if (!workspaceUuid) { throw new Error("Workspace UUID not found in config"); } const response = await axios({ url: 'https://api.datacake.co/graphql/', method: 'post', headers: { 'Content-Type': 'application/json', 'Authorization': `Token ${configNode.credentials.workspaceToken}` }, data: { query: ` query { workspace(id:"${workspaceUuid}") { allTags } } ` } }); if (response.data.errors) { throw new Error(response.data.errors[0].message); } res.status(200).json(response.data); } catch (error) { res.status(500).json({ error: error.message }); } }); RED.nodes.registerType("datacakegraphql-product-stats", DatacakeGraphQLProductStatsNode, { credentials: {} }); }