update dashboardAPI -AGENT

This commit is contained in:
znetsixe
2026-01-13 14:29:43 +01:00
parent c99a93f73b
commit 1ea4788848
16 changed files with 1202 additions and 8393 deletions

71
config/dashboardapi.json Normal file
View File

@@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "DashboardAPI", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> aggregateWindow(every: v.windowPeriod, fn: count, createEmpty: false)",
"refId": "A"
}
],
"title": "Upsert Activity (if logged)",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "dashboardapi", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Pumping Station", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
"id": 2,
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -7d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"direction\" or r._field==\"flowSource\" or r._field==\"timeleft\"))\n |> last()",
"refId": "A"
}
],
"title": "Direction / Source / Timeleft (last)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"level.measured.atequipment\" or r._field==\"level.predicted.atequipment\" or r._field==\"volume.predicted.atequipment\" or r._field==\"netFlowRate.predicted.atequipment\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Level / Volume / Net Flow",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "pumpingStation", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

71
config/reactor.json Normal file
View File

@@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Reactor (Simulation/Process)", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F|S_O|S_NH|S_NO|temperature)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Core Process Signals (if logged)",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "reactor", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

71
config/settler.json Normal file
View File

@@ -0,0 +1,71 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Settler (Simulation/Process)", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 },
"id": 2,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\")\n |> filter(fn:(r) => r._field =~ /^(F_in|F_eff|F_so|F_sr|C_TS)/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flows / Solids (if logged)",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "settler", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

85
config/valve.json Normal file
View File

@@ -0,0 +1,85 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
"id": 2,
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"state\" or r._field==\"mode\" or r._field==\"percentageOpen\"))\n |> last()",
"refId": "A"
}
],
"title": "State / Mode / %Open (last)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"downstream_predicted_flow\" or r._field==\"downstream_measured_flow\" or r._field==\"delta_predicted_pressure\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flow + ΔP",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "valve", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

View File

@@ -0,0 +1,85 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Realtime Valve Group", "type": "row" },
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 1 },
"id": 2,
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: -30d)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"mode\" or r._field==\"maxDeltaP\"))\n |> last()",
"refId": "A"
}
],
"title": "Mode / maxΔP (last)",
"type": "stat"
},
{
"datasource": { "type": "influxdb", "uid": "cdzg44tv250jkd" },
"fieldConfig": { "defaults": { "unit": "none" }, "overrides": [] },
"gridPos": { "h": 9, "w": 16, "x": 8, "y": 1 },
"id": 3,
"options": { "legend": { "displayMode": "list", "placement": "bottom" } },
"targets": [
{
"query": "from(bucket: \"${bucket}\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn:(r) => r._measurement==\"${measurement}\" and (r._field==\"atequipment_measured_flow\" or r._field==\"atequipment_predicted_flow\" or r._field==\"maxDeltaP\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)",
"refId": "A"
}
],
"title": "Flow + maxΔP",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": ["EVOLV", "valveGroupControl", "template"],
"templating": {
"list": [
{
"name": "dbase",
"type": "custom",
"label": "dbase",
"query": "cdzg44tv250jkd",
"current": { "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": false },
"options": [{ "text": "cdzg44tv250jkd", "value": "cdzg44tv250jkd", "selected": true }],
"hide": 2
},
{
"name": "measurement",
"type": "custom",
"query": "template",
"current": { "text": "template", "value": "template", "selected": false },
"options": [{ "text": "template", "value": "template", "selected": true }]
},
{
"name": "bucket",
"type": "custom",
"query": "lvl2",
"current": { "text": "lvl2", "value": "lvl2", "selected": false },
"options": [{ "text": "lvl2", "value": "lvl2", "selected": true }]
}
]
},
"time": { "from": "now-6h", "to": "now" },
"timezone": "",
"title": "template",
"uid": null,
"version": 1
}

View File

@@ -1,134 +1,88 @@
<script type="module">
<script src="/dashboardapi/menu.js"></script>
<script src="/dashboardapi/configData.js"></script>
//import * as menuUtils from "/generalfunctions/helper/menuUtils.js";
<script>
RED.nodes.registerType('dashboardapi', {
category: 'EVOLV',
color: '#4f8582',
defaults: {
name: { value: '' },
enableLog: { value: false },
logLevel: { value: 'info' },
RED.nodes.registerType('dashboardapi', {
category: 'wbd typical',
color: '#4f8582',
defaults: {
name: { value: "" },
// New defaults for configuration:
logLevel: { value: "info" },
enableLog: { value: false },
host: { value: "" },
port: { value: 0 },
bearerToken: { value: "" }
},
inputs: 1,
outputs: 1,
inputLabels: "Usage see manual",
outputLabels: ["feedback"],
icon: "font-awesome/fa-area-chart",
protocol: { value: 'http' },
host: { value: 'localhost' },
port: { value: 3000 },
bearerToken: { value: '' },
},
inputs: 1,
outputs: 1,
inputLabels: ['Input'],
outputLabels: ['grafana'],
icon: 'font-awesome/fa-area-chart',
label: function () {
// Show the name
return this.name || "dashboardapi";
},
label: function () {
return this.name || 'dashboardapi';
},
oneditprepare: function () {
const node = this;
console.log("Edit Prepare");
const elements = {
// Basic fields
name: document.getElementById("node-input-name"),
number: document.getElementById("node-input-number"),
// Logging fields
logLevelSelect: document.getElementById("node-input-logLevel"),
logCheckbox: document.getElementById("node-input-enableLog"),
// Grafana connector fields
host: document.getElementById("node-input-host"),
port: document.getElementById("node-input-port"),
bearerToken: document.getElementById("node-input-bearerToken"),
};
try {
// UI elements
menuUtils.initBasicToggles(elements);
} catch (e) {
console.log("Error fetching project settings", e);
}
},
oneditsave: function () {
const node = this;
console.log(`------------ Saving changes to node ------------`);
//save basic properties
["name", "host", "port", "bearerToken"].forEach(
(field) => {
const element = document.getElementById(`node-input-${field}`);
if (element) {
node[field] = element.value || "";
}
}
);
const logLevelElement = document.getElementById("node-input-logLevel");
node.logLevel = logLevelElement ? logLevelElement.value || "info" : "info";
oneditprepare: function () {
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.dashboardapi?.loggerMenu?.initEditor) {
window.EVOLV.nodes.dashboardapi.loggerMenu.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
},
});
oneditsave: function () {
const node = this;
if (window.EVOLV?.nodes?.dashboardapi?.loggerMenu?.saveEditor) {
window.EVOLV.nodes.dashboardapi.loggerMenu.saveEditor(node);
}
['name', 'protocol', 'host', 'port', 'bearerToken'].forEach((field) => {
const element = document.getElementById(`node-input-${field}`);
if (!element) return;
node[field] = field === 'port' ? parseInt(element.value, 10) || 3000 : element.value || '';
});
},
});
</script>
<!-- Main UI Template -->
<script type="text/html" data-template-name="dashboardapi">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input
type="text"
id="node-input-name"
placeholder="name"
style="width:70%;"
/>
</div>
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="name" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-protocol"><i class="fa fa-exchange"></i> Protocol</label>
<select id="node-input-protocol" style="width:70%;">
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
<div class="form-row">
<label for="node-input-host"><i class="fa fa-server"></i> Grafana Host</label>
<input type="text" id="node-input-host" placeholder="Host">
<input type="text" id="node-input-host" placeholder="localhost" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-port"><i class="fa fa-plug"></i> Grafana Port</label>
<input type="number" id="node-input-port" placeholder="Port">
<input type="number" id="node-input-port" placeholder="3000" style="width:70%;" />
</div>
<div class="form-row">
<label for="node-input-bearerToken"><i class="fa fa-key"></i> Bearer Token</label>
<input type="text" id="node-input-bearerToken" placeholder="Bearer Token">
<input type="password" id="node-input-bearerToken" placeholder="optional" style="width:70%;" />
</div>
<hr />
<!-- loglevel checkbox -->
<div class="form-row">
<label for="node-input-enableLog"
><i class="fa fa-cog"></i> Enable Log</label
>
<input
type="checkbox"
id="node-input-enableLog"
style="width:20px; vertical-align:baseline;"
/>
<span>Enable logging</span>
</div>
<div class="form-row" id="row-logLevel">
<label for="node-input-logLevel"><i class="fa fa-cog"></i> Log Level</label>
<select id="node-input-logLevel" style="width:60%;">
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
<div id="logger-fields-placeholder"></div>
</script>
<script type="text/html" data-help-name="dashboardapi">
@@ -146,4 +100,4 @@
These features provide flexible and controlled interactions with the Grafana API.
</p>
</script>
</script>

View File

@@ -1,108 +1,41 @@
const fs = require('node:fs');
const path = require('node:path');
const nameOfNode = 'dashboardapi';
const nodeClass = require('./src/nodeClass.js');
const { MenuManager } = require('generalFunctions');
module.exports = function (RED) {
function dashboardapi(config) {
// create node
RED.nodes.registerType(nameOfNode, function (config) {
RED.nodes.createNode(this, config);
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
//call this => node so whenver you want to call a node function type node and the function behind it
var node = this;
const menuMgr = new MenuManager();
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
//fetch obj
const Dashboardapi = require("./dependencies/dashboardapi/dashboardapi_class");
//load user defined config in the node-red UI
const dConfig = {
general: {
name: config.name,
id: node.id,
logging: {
logLevel: config.logLevel,
enabled: config.enableLog,
},
},
grafanaConnector: {
host: config.host,
port: config.port,
bearerToken: config.bearerToken,
},
};
//make new measurement on creation to work with.
const d = new Dashboardapi(dConfig);
// put m on node memory as source
node.source = d;
function updateNodeStatus(val) {
if (val && val.grafanaResponse) {
// Check for a successful response from the Grafana API call
if (val.grafanaResponse.status === 200) {
node.status({
fill: "green",
shape: "dot",
text: "Grafana API: Success",
});
node.log("Grafana API call completed successfully.");
} else {
node.status({
fill: "red",
shape: "ring",
text: "Grafana API: Error",
});
node.error(
"Grafana API call failed with status: " +
val.grafanaResponse.status
);
}
}
}
//-------------------------------------------------------------------->>what to do on input
node.on("input", async function (msg, send, done) {
try {
switch(msg.topic) {
//on start make dashboard
case 'registerChild':
const childId = msg.payload;
const childObj = RED.nodes.getNode(childId);
if (!childObj || !childObj.source) {
throw new Error("Missing or invalid child node");
}
const child = childObj.source;
msg.payload = await d.generateDashB(child.config);
msg.topic = "create";
msg.headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer glsa_gI7fOMEd844p1gZt9iaDeEFpeYtejRj7_cf1c41f8'// + config.bearerToken
};
console.log(`Child registered: ${childId}`);
send(msg);
break;
}
done();
} catch (err) {
node.status({ fill: "red", shape: "ring", text: "Bad request data" });
node.error("Bad request data: " + err.message, msg);
done(err);
}
});
// tidy up any async code here - shutdown connections and so on.
node.on("close", function () {
});
} catch (e) {
console.log(e);
const script = menuMgr.createEndpoint(nameOfNode, ['logger']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
}
});
RED.nodes.registerType("dashboardapi", dashboardapi);
// Provide config metadata for the editor (local, no dependency on generalFunctions configs).
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
try {
const configPath = path.join(__dirname, 'dependencies', 'dashboardapi', 'dashboardapiConfig.json');
const json = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const script = `
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nameOfNode} = window.EVOLV.nodes.${nameOfNode} || {};
window.EVOLV.nodes.${nameOfNode}.config = ${JSON.stringify(json, null, 2)};
`;
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
};

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "dashboardAPI",
"version": "1.0.0",
"description": "EVOLV Grafana dashboard generator (Node-RED node).",
"main": "dashboardapi.js",
"scripts": {
"test": "node --test test/*.test.js"
},
"keywords": [
"dashboard",
"grafana",
"node-red",
"EVOLV"
],
"author": "EVOLV",
"license": "SEE LICENSE",
"dependencies": {
"generalFunctions": "git+https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions.git"
},
"node-red": {
"nodes": {
"dashboardapi": "dashboardapi.js"
}
}
}

103
src/nodeClass.js Normal file
View File

@@ -0,0 +1,103 @@
const { outputUtils } = require('generalFunctions');
const Specific = require('./specificClass');
class nodeClass {
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this.source = null;
this.config = null;
this._loadConfig(uiConfig);
this._setupSpecificClass();
this._attachInputHandler();
this._attachCloseHandler();
}
_loadConfig(uiConfig) {
this.config = {
general: {
name: uiConfig.name || this.name,
logging: {
enabled: uiConfig.enableLog,
logLevel: uiConfig.logLevel || 'info',
},
},
grafanaConnector: {
protocol: uiConfig.protocol || 'http',
host: uiConfig.host || 'localhost',
port: Number(uiConfig.port || 3000),
bearerToken: uiConfig.bearerToken || '',
},
};
this._output = new outputUtils();
}
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source;
}
_attachInputHandler() {
this.node.on('input', async (msg, send, done) => {
try {
if (msg.topic !== 'registerChild') {
done();
return;
}
const childId = msg.payload;
const childObj = this.RED.nodes.getNode(childId);
const childSource = childObj?.source;
if (!childSource?.config) {
throw new Error(`Missing child source/config for id=${childId}`);
}
const dashboards = this.source.generateDashboardsForGraph(childSource, {
includeChildren: Boolean(msg.includeChildren ?? true),
});
const url = this.source.grafanaUpsertUrl();
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
};
if (this.config.grafanaConnector.bearerToken) {
headers.Authorization = `Bearer ${this.config.grafanaConnector.bearerToken}`;
}
for (const dash of dashboards) {
const payload = this.source.buildUpsertRequest({ dashboard: dash.dashboard, folderId: 0, overwrite: true });
send({
topic: 'grafana.dashboard.upsert',
url,
method: 'POST',
headers,
payload,
meta: {
nodeId: dash.nodeId,
softwareType: dash.softwareType,
uid: dash.uid,
title: dash.title,
},
});
}
done();
} catch (error) {
this.node.status({ fill: 'red', shape: 'ring', text: 'dashboardapi error' });
this.node.error(error?.message || error, msg);
done(error);
}
});
}
_attachCloseHandler() {
this.node.on('close', (done) => done());
}
}
module.exports = nodeClass;

195
src/specificClass.js Normal file
View File

@@ -0,0 +1,195 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { logger } = require('generalFunctions');
function stableUid(input) {
const digest = crypto.createHash('sha1').update(String(input)).digest('hex');
return digest.slice(0, 12);
}
function slugify(input) {
return String(input || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 60);
}
function defaultBucketForPosition(positionVsParent) {
const pos = String(positionVsParent || '').toLowerCase();
if (pos === 'upstream') return 'lvl1';
if (pos === 'downstream') return 'lvl3';
return 'lvl2';
}
function updateTemplatingVar(dashboard, varName, value) {
const list = dashboard?.templating?.list;
if (!Array.isArray(list)) return;
const variable = list.find((v) => v && v.name === varName);
if (!variable) return;
variable.current = variable.current || {};
variable.current.text = value;
variable.current.value = value;
if (Array.isArray(variable.options) && variable.options.length > 0) {
variable.options[0] = variable.options[0] || {};
variable.options[0].text = value;
variable.options[0].value = value;
}
variable.query = value;
}
class DashboardApi {
constructor(config = {}) {
this.config = {
general: {
name: config?.general?.name || 'dashboardapi',
logging: {
enabled: Boolean(config?.general?.logging?.enabled),
logLevel: config?.general?.logging?.logLevel || 'info',
},
},
grafanaConnector: {
protocol: config?.grafanaConnector?.protocol || 'http',
host: config?.grafanaConnector?.host || 'localhost',
port: Number(config?.grafanaConnector?.port || 3000),
bearerToken: config?.grafanaConnector?.bearerToken || '',
},
bucketMap: config?.bucketMap || {},
};
this.logger = new logger(
this.config.general.logging.enabled,
this.config.general.logging.logLevel,
this.config.general.name
);
}
_templatesDir() {
return path.join(__dirname, '..', 'config');
}
_templateFileForSoftwareType(softwareType) {
const st = String(softwareType || '').trim();
const candidates = [
`${st}.json`,
`${st.toLowerCase()}.json`,
st === 'machineGroupControl' ? 'machineGroup.json' : null,
].filter(Boolean);
for (const filename of candidates) {
const fullPath = path.join(this._templatesDir(), filename);
if (fs.existsSync(fullPath)) return fullPath;
}
throw new Error(`No dashboard template found for softwareType=${st}`);
}
loadTemplate(softwareType) {
const templatePath = this._templateFileForSoftwareType(softwareType);
const raw = fs.readFileSync(templatePath, 'utf8');
return JSON.parse(raw);
}
grafanaUpsertUrl() {
const { protocol, host, port } = this.config.grafanaConnector;
return `${protocol}://${host}:${port}/api/dashboards/db`;
}
buildDashboard({ nodeConfig, positionVsParent }) {
const softwareType =
nodeConfig?.functionality?.softwareType ||
nodeConfig?.functionality?.software_type ||
'measurement';
const nodeId = nodeConfig?.general?.id || nodeConfig?.general?.name || softwareType;
const measurementName = `${softwareType}_${nodeId}`;
const title = nodeConfig?.general?.name || String(nodeId);
const dashboard = this.loadTemplate(softwareType);
const uid = stableUid(`${softwareType}:${nodeId}`);
dashboard.id = null;
dashboard.uid = uid;
dashboard.title = title;
dashboard.tags = Array.from(
new Set([...(dashboard.tags || []), 'EVOLV', softwareType, String(positionVsParent || '')].filter(Boolean))
);
const bucket =
this.config.bucketMap[String(positionVsParent)] || defaultBucketForPosition(positionVsParent);
updateTemplatingVar(dashboard, 'measurement', measurementName);
updateTemplatingVar(dashboard, 'bucket', bucket);
return { dashboard, uid, title, softwareType, nodeId, measurementName };
}
buildUpsertRequest({ dashboard, folderId = 0, overwrite = true }) {
return { dashboard, folderId, overwrite };
}
extractChildren(nodeSource) {
const out = [];
const reg = nodeSource?.childRegistrationUtils?.registeredChildren;
if (reg && typeof reg.values === 'function') {
for (const entry of reg.values()) {
const child = entry?.child;
if (!child?.config) continue;
out.push({ childSource: child, positionVsParent: entry?.position || child.positionVsParent });
}
return out;
}
return out;
}
generateDashboardsForGraph(rootSource, { includeChildren = true } = {}) {
if (!rootSource?.config) {
throw new Error('generateDashboardsForGraph requires a node source with `.config`');
}
const rootPosition = rootSource?.positionVsParent || rootSource?.config?.functionality?.positionVsParent;
const rootDash = this.buildDashboard({ nodeConfig: rootSource.config, positionVsParent: rootPosition });
const results = [rootDash];
if (!includeChildren) return results;
const children = this.extractChildren(rootSource);
for (const { childSource, positionVsParent } of children) {
const childDash = this.buildDashboard({ nodeConfig: childSource.config, positionVsParent });
results.push(childDash);
}
// Add links from the root dashboard to children dashboards (when possible)
if (children.length > 0) {
rootDash.dashboard.links = Array.isArray(rootDash.dashboard.links) ? rootDash.dashboard.links : [];
for (const { childSource } of children) {
const childConfig = childSource.config;
const childSoftwareType = childConfig?.functionality?.softwareType || 'measurement';
const childNodeId = childConfig?.general?.id || childConfig?.general?.name || childSoftwareType;
const childUid = stableUid(`${childSoftwareType}:${childNodeId}`);
const childTitle = childConfig?.general?.name || String(childNodeId);
rootDash.dashboard.links.push({
type: 'link',
title: childTitle,
url: `/d/${childUid}/${slugify(childTitle)}`,
tags: [],
targetBlank: false,
keepTime: true,
keepVariables: true,
});
}
}
return results;
}
}
module.exports = DashboardApi;

82
test/dashboardapi.test.js Normal file
View File

@@ -0,0 +1,82 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const DashboardApi = require('../src/specificClass');
function makeNodeSource({ id, name, softwareType, positionVsParent, children = [] }) {
const registeredChildren = new Map();
for (const child of children) {
registeredChildren.set(child.config.general.id, {
child,
softwareType: child.config.functionality.softwareType,
position: child.positionVsParent || child.config.functionality.positionVsParent,
registeredAt: Date.now(),
});
}
return {
config: {
general: { id, name },
functionality: { softwareType, positionVsParent },
},
positionVsParent,
childRegistrationUtils: { registeredChildren },
};
}
test('buildDashboard sets id=null, stable uid, title, measurement and bucket vars', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const nodeSource = makeNodeSource({
id: 'm-1',
name: 'PT-1',
softwareType: 'measurement',
positionVsParent: 'downstream',
});
const dash = api.buildDashboard({ nodeConfig: nodeSource.config, positionVsParent: 'downstream' });
assert.equal(dash.dashboard.id, null);
assert.equal(dash.uid.length, 12);
assert.equal(dash.dashboard.uid, dash.uid);
assert.equal(dash.dashboard.title, 'PT-1');
const templ = dash.dashboard.templating.list;
const measurement = templ.find((v) => v.name === 'measurement');
const bucket = templ.find((v) => v.name === 'bucket');
assert.equal(measurement.current.value, 'measurement_m-1');
assert.equal(bucket.current.value, 'lvl3');
});
test('generateDashboardsForGraph returns root + direct child dashboards and adds links', () => {
const api = new DashboardApi({
general: { name: 'dashboardapi-test', logging: { enabled: false, logLevel: 'error' } },
grafanaConnector: { protocol: 'http', host: 'localhost', port: 3000, bearerToken: '' },
});
const child = makeNodeSource({
id: 'c-1',
name: 'ChildSensor',
softwareType: 'measurement',
positionVsParent: 'upstream',
});
const root = makeNodeSource({
id: 'p-1',
name: 'ParentMachine',
softwareType: 'machine',
positionVsParent: 'atEquipment',
children: [child],
});
const results = api.generateDashboardsForGraph(root, { includeChildren: true });
assert.equal(results.length, 2);
const rootDash = results[0];
assert.ok(Array.isArray(rootDash.dashboard.links));
assert.ok(rootDash.dashboard.links.some((l) => l.url && l.url.includes('/d/')));
});