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
JavaScript
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: {}
});
}