node-red-contrib-questdb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/nodes/mapper.html +205 -0
- package/nodes/mapper.js +113 -0
- package/nodes/write.html +289 -0
- package/nodes/write.js +403 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# node-red-contrib-questdb
|
|
2
|
+
|
|
3
|
+
Node-RED nodes for QuestDB time-series database.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install node-red-contrib-questdb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Nodes
|
|
12
|
+
|
|
13
|
+
### questdb
|
|
14
|
+
|
|
15
|
+
A node for interacting with QuestDB time-series database.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('questdb-mapper', {
|
|
3
|
+
category: 'questdb',
|
|
4
|
+
color: '#a23154',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: ""},
|
|
7
|
+
tableName: {value: ""},
|
|
8
|
+
timestampField: {value: ""},
|
|
9
|
+
symbolMappings: {value: []},
|
|
10
|
+
columnMappings: {value: []}
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: "font-awesome/fa-exchange",
|
|
15
|
+
label: function() {
|
|
16
|
+
return this.name || "Mapper";
|
|
17
|
+
},
|
|
18
|
+
paletteLabel: "Mapper",
|
|
19
|
+
labelStyle: function() {
|
|
20
|
+
return this.name ? "node_label_italic questdb-white-text" : "questdb-white-text";
|
|
21
|
+
},
|
|
22
|
+
oneditprepare: function() {
|
|
23
|
+
const node = this;
|
|
24
|
+
|
|
25
|
+
// Symbol mappings list
|
|
26
|
+
const symbolList = $("#node-input-symbol-list").css({
|
|
27
|
+
'min-height': '100px',
|
|
28
|
+
'min-width': '400px'
|
|
29
|
+
}).editableList({
|
|
30
|
+
addItem: function(container, i, data) {
|
|
31
|
+
const row = $('<div/>').appendTo(container);
|
|
32
|
+
$('<input/>', {type: "text", placeholder: "msg.field", class: "symbol-source"})
|
|
33
|
+
.css({width: "45%", marginRight: "5px"})
|
|
34
|
+
.val(data.source || "")
|
|
35
|
+
.appendTo(row);
|
|
36
|
+
$('<span/>').text(" → ").appendTo(row);
|
|
37
|
+
$('<input/>', {type: "text", placeholder: "symbol name", class: "symbol-target"})
|
|
38
|
+
.css({width: "40%", marginLeft: "5px"})
|
|
39
|
+
.val(data.target || "")
|
|
40
|
+
.appendTo(row);
|
|
41
|
+
},
|
|
42
|
+
removable: true,
|
|
43
|
+
sortable: true
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Column mappings list
|
|
47
|
+
const columnList = $("#node-input-column-list").css({
|
|
48
|
+
'min-height': '100px',
|
|
49
|
+
'min-width': '400px'
|
|
50
|
+
}).editableList({
|
|
51
|
+
addItem: function(container, i, data) {
|
|
52
|
+
const row = $('<div/>').appendTo(container);
|
|
53
|
+
$('<input/>', {type: "text", placeholder: "msg.field", class: "column-source"})
|
|
54
|
+
.css({width: "35%", marginRight: "5px"})
|
|
55
|
+
.val(data.source || "")
|
|
56
|
+
.appendTo(row);
|
|
57
|
+
$('<span/>').text(" → ").appendTo(row);
|
|
58
|
+
$('<input/>', {type: "text", placeholder: "column name", class: "column-target"})
|
|
59
|
+
.css({width: "30%", marginLeft: "5px", marginRight: "5px"})
|
|
60
|
+
.val(data.target || "")
|
|
61
|
+
.appendTo(row);
|
|
62
|
+
const typeSelect = $('<select/>', {class: "column-type"})
|
|
63
|
+
.css({width: "20%"})
|
|
64
|
+
.appendTo(row);
|
|
65
|
+
['auto', 'float', 'integer', 'string', 'boolean', 'timestamp'].forEach(function(t) {
|
|
66
|
+
$('<option/>').val(t).text(t).appendTo(typeSelect);
|
|
67
|
+
});
|
|
68
|
+
typeSelect.val(data.type || "auto");
|
|
69
|
+
},
|
|
70
|
+
removable: true,
|
|
71
|
+
sortable: true
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Load existing mappings
|
|
75
|
+
if (node.symbolMappings) {
|
|
76
|
+
node.symbolMappings.forEach(function(m) {
|
|
77
|
+
symbolList.editableList('addItem', m);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (node.columnMappings) {
|
|
81
|
+
node.columnMappings.forEach(function(m) {
|
|
82
|
+
columnList.editableList('addItem', m);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
oneditresize: function(size) {
|
|
87
|
+
// Resize editable lists if needed
|
|
88
|
+
},
|
|
89
|
+
oneditsave: function() {
|
|
90
|
+
const node = this;
|
|
91
|
+
|
|
92
|
+
// Save symbol mappings
|
|
93
|
+
node.symbolMappings = [];
|
|
94
|
+
$("#node-input-symbol-list").editableList('items').each(function() {
|
|
95
|
+
const source = $(this).find(".symbol-source").val().trim();
|
|
96
|
+
const target = $(this).find(".symbol-target").val().trim();
|
|
97
|
+
if (source && target) {
|
|
98
|
+
node.symbolMappings.push({source: source, target: target});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Save column mappings
|
|
103
|
+
node.columnMappings = [];
|
|
104
|
+
$("#node-input-column-list").editableList('items').each(function() {
|
|
105
|
+
const source = $(this).find(".column-source").val().trim();
|
|
106
|
+
const target = $(this).find(".column-target").val().trim();
|
|
107
|
+
const type = $(this).find(".column-type").val();
|
|
108
|
+
if (source && target) {
|
|
109
|
+
node.columnMappings.push({source: source, target: target, type: type});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<script type="text/html" data-template-name="questdb-mapper">
|
|
117
|
+
<div class="form-row">
|
|
118
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
119
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
120
|
+
</div>
|
|
121
|
+
<div class="form-row">
|
|
122
|
+
<label for="node-input-tableName"><i class="fa fa-table"></i> Table Name</label>
|
|
123
|
+
<input type="text" id="node-input-tableName" placeholder="Leave empty to use msg.topic">
|
|
124
|
+
</div>
|
|
125
|
+
<div class="form-row">
|
|
126
|
+
<label for="node-input-timestampField"><i class="fa fa-clock-o"></i> Timestamp Field</label>
|
|
127
|
+
<input type="text" id="node-input-timestampField" placeholder="e.g. payload.timestamp">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="form-row">
|
|
130
|
+
<label style="width:100%;"><i class="fa fa-bookmark"></i> Symbol Mappings (indexed tags)</label>
|
|
131
|
+
<ol id="node-input-symbol-list"></ol>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="form-row">
|
|
134
|
+
<label style="width:100%;"><i class="fa fa-columns"></i> Column Mappings (values)</label>
|
|
135
|
+
<ol id="node-input-column-list"></ol>
|
|
136
|
+
</div>
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<script type="text/html" data-help-name="questdb-mapper">
|
|
140
|
+
<p>Maps message fields to QuestDB ILP structure for use with the Write node.</p>
|
|
141
|
+
|
|
142
|
+
<h3>Properties</h3>
|
|
143
|
+
<dl class="message-properties">
|
|
144
|
+
<dt>Table Name</dt>
|
|
145
|
+
<dd>Target table name. If empty, uses <code>msg.topic</code></dd>
|
|
146
|
+
<dt>Timestamp Field</dt>
|
|
147
|
+
<dd>Path to timestamp field (e.g. <code>payload.ts</code>)</dd>
|
|
148
|
+
<dt>Symbol Mappings</dt>
|
|
149
|
+
<dd>Map msg fields to QuestDB symbols (indexed string columns)</dd>
|
|
150
|
+
<dt>Column Mappings</dt>
|
|
151
|
+
<dd>Map msg fields to QuestDB columns with type conversion</dd>
|
|
152
|
+
</dl>
|
|
153
|
+
|
|
154
|
+
<h3>Field Path Syntax</h3>
|
|
155
|
+
<p>Use dot notation: <code>payload.sensor.value</code></p>
|
|
156
|
+
<p>Array access: <code>payload.readings[0]</code></p>
|
|
157
|
+
|
|
158
|
+
<h3>Output</h3>
|
|
159
|
+
<p>Produces a message ready for the QuestDB Write node:</p>
|
|
160
|
+
<pre>{
|
|
161
|
+
topic: "table_name",
|
|
162
|
+
payload: {
|
|
163
|
+
symbols: { ... },
|
|
164
|
+
columns: { ... },
|
|
165
|
+
timestamp: ...
|
|
166
|
+
}
|
|
167
|
+
}</pre>
|
|
168
|
+
|
|
169
|
+
<h3>Example</h3>
|
|
170
|
+
<p>Input message:</p>
|
|
171
|
+
<pre>{
|
|
172
|
+
topic: "sensors",
|
|
173
|
+
payload: {
|
|
174
|
+
device: "sensor1",
|
|
175
|
+
temp: 23.5,
|
|
176
|
+
ts: 1699999999000
|
|
177
|
+
}
|
|
178
|
+
}</pre>
|
|
179
|
+
<p>With mappings:</p>
|
|
180
|
+
<ul>
|
|
181
|
+
<li>Symbol: <code>payload.device</code> → <code>device_id</code></li>
|
|
182
|
+
<li>Column: <code>payload.temp</code> → <code>temperature</code> (float)</li>
|
|
183
|
+
<li>Timestamp: <code>payload.ts</code></li>
|
|
184
|
+
</ul>
|
|
185
|
+
<p>Output:</p>
|
|
186
|
+
<pre>{
|
|
187
|
+
topic: "sensors",
|
|
188
|
+
payload: {
|
|
189
|
+
symbols: { device_id: "sensor1" },
|
|
190
|
+
columns: { temperature: 23.5 },
|
|
191
|
+
timestamp: 1699999999000
|
|
192
|
+
}
|
|
193
|
+
}</pre>
|
|
194
|
+
</script>
|
|
195
|
+
|
|
196
|
+
<style>
|
|
197
|
+
/* White text on flow canvas */
|
|
198
|
+
.questdb-white-text {
|
|
199
|
+
fill: #ffffff !important;
|
|
200
|
+
}
|
|
201
|
+
/* White text in palette */
|
|
202
|
+
.red-ui-palette-node[data-palette-type="questdb-mapper"] .red-ui-palette-label {
|
|
203
|
+
color: #ffffff !important;
|
|
204
|
+
}
|
|
205
|
+
</style>
|
package/nodes/mapper.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function QuestDBMapperNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
// Configuration from node properties
|
|
7
|
+
node.tableName = config.tableName;
|
|
8
|
+
node.timestampField = config.timestampField;
|
|
9
|
+
node.symbolMappings = config.symbolMappings || [];
|
|
10
|
+
node.columnMappings = config.columnMappings || [];
|
|
11
|
+
|
|
12
|
+
node.on('input', function(msg, send, done) {
|
|
13
|
+
try {
|
|
14
|
+
const output = {
|
|
15
|
+
topic: node.tableName || msg.topic,
|
|
16
|
+
payload: {
|
|
17
|
+
symbols: {},
|
|
18
|
+
columns: {}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Map timestamp
|
|
23
|
+
if (node.timestampField) {
|
|
24
|
+
const tsValue = getNestedValue(msg, node.timestampField);
|
|
25
|
+
if (tsValue !== undefined) {
|
|
26
|
+
output.payload.timestamp = tsValue;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Map symbols
|
|
31
|
+
for (const mapping of node.symbolMappings) {
|
|
32
|
+
if (mapping.source && mapping.target) {
|
|
33
|
+
const value = getNestedValue(msg, mapping.source);
|
|
34
|
+
if (value !== undefined && value !== null) {
|
|
35
|
+
output.payload.symbols[mapping.target] = String(value);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Map columns
|
|
41
|
+
for (const mapping of node.columnMappings) {
|
|
42
|
+
if (mapping.source && mapping.target) {
|
|
43
|
+
let value = getNestedValue(msg, mapping.source);
|
|
44
|
+
if (value !== undefined && value !== null) {
|
|
45
|
+
// Type conversion based on mapping type
|
|
46
|
+
switch (mapping.type) {
|
|
47
|
+
case 'float':
|
|
48
|
+
value = parseFloat(value);
|
|
49
|
+
if (isNaN(value)) continue;
|
|
50
|
+
break;
|
|
51
|
+
case 'integer':
|
|
52
|
+
value = parseInt(value, 10);
|
|
53
|
+
if (isNaN(value)) continue;
|
|
54
|
+
break;
|
|
55
|
+
case 'boolean':
|
|
56
|
+
value = Boolean(value);
|
|
57
|
+
break;
|
|
58
|
+
case 'string':
|
|
59
|
+
value = String(value);
|
|
60
|
+
break;
|
|
61
|
+
case 'timestamp':
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
value = new Date(value);
|
|
64
|
+
} else if (typeof value === 'number') {
|
|
65
|
+
value = new Date(value);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
// 'auto' - keep original type
|
|
69
|
+
}
|
|
70
|
+
output.payload.columns[mapping.target] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
node.status({fill:"green", shape:"dot", text:`mapped: ${output.topic}`});
|
|
76
|
+
send(output);
|
|
77
|
+
done();
|
|
78
|
+
|
|
79
|
+
} catch (err) {
|
|
80
|
+
node.status({fill:"red", shape:"ring", text:"error"});
|
|
81
|
+
done(err);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Helper function to get nested property value
|
|
86
|
+
function getNestedValue(obj, path) {
|
|
87
|
+
// Strip 'msg.' prefix if present since we're already working with msg object
|
|
88
|
+
if (path.startsWith('msg.')) {
|
|
89
|
+
path = path.substring(4);
|
|
90
|
+
}
|
|
91
|
+
const parts = path.split('.');
|
|
92
|
+
let current = obj;
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
if (current === undefined || current === null) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
// Handle array notation like payload[0]
|
|
98
|
+
const match = part.match(/^(\w+)\[(\d+)\]$/);
|
|
99
|
+
if (match) {
|
|
100
|
+
current = current[match[1]];
|
|
101
|
+
if (Array.isArray(current)) {
|
|
102
|
+
current = current[parseInt(match[2], 10)];
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
current = current[part];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
RED.nodes.registerType("questdb-mapper", QuestDBMapperNode);
|
|
113
|
+
};
|
package/nodes/write.html
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
// Configuration node
|
|
3
|
+
RED.nodes.registerType('questdb-config',{
|
|
4
|
+
category: 'config',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value:""},
|
|
7
|
+
protocol: {value:"http"},
|
|
8
|
+
host: {value:"localhost", required:true},
|
|
9
|
+
port: {value:9000, required:true, validate:RED.validators.number()},
|
|
10
|
+
tlsVerify: {value:true},
|
|
11
|
+
tlsCa: {value:""},
|
|
12
|
+
autoFlush: {value:true},
|
|
13
|
+
autoFlushRows: {value:75000, validate:RED.validators.number()},
|
|
14
|
+
autoFlushInterval: {value:1000, validate:RED.validators.number()},
|
|
15
|
+
requestTimeout: {value:10000, validate:RED.validators.number()},
|
|
16
|
+
retryTimeout: {value:10000, validate:RED.validators.number()},
|
|
17
|
+
initBufSize: {value:65536, validate:RED.validators.number()},
|
|
18
|
+
maxBufSize: {value:104857600, validate:RED.validators.number()},
|
|
19
|
+
useAuth: {value:false},
|
|
20
|
+
authType: {value:"basic"}
|
|
21
|
+
},
|
|
22
|
+
credentials: {
|
|
23
|
+
username: {type:"text"},
|
|
24
|
+
password: {type:"password"},
|
|
25
|
+
token: {type:"password"}
|
|
26
|
+
},
|
|
27
|
+
label: function() {
|
|
28
|
+
return this.name || `${this.host}:${this.port}`;
|
|
29
|
+
},
|
|
30
|
+
oneditprepare: function() {
|
|
31
|
+
var tabs = RED.tabs.create({
|
|
32
|
+
id: "questdb-config-tabs",
|
|
33
|
+
onchange: function(tab) {
|
|
34
|
+
$("#questdb-config-tabs-content").children().hide();
|
|
35
|
+
$("#" + tab.id).show();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
tabs.addTab({
|
|
39
|
+
id: "questdb-tab-connection",
|
|
40
|
+
label: "Connection"
|
|
41
|
+
});
|
|
42
|
+
tabs.addTab({
|
|
43
|
+
id: "questdb-tab-security",
|
|
44
|
+
label: "Security"
|
|
45
|
+
});
|
|
46
|
+
tabs.addTab({
|
|
47
|
+
id: "questdb-tab-advanced",
|
|
48
|
+
label: "Advanced"
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Show/hide TLS options based on protocol
|
|
52
|
+
$("#node-config-input-protocol").on("change", function() {
|
|
53
|
+
var proto = $(this).val();
|
|
54
|
+
if (proto === "https" || proto === "tcps") {
|
|
55
|
+
$(".tls-row").show();
|
|
56
|
+
} else {
|
|
57
|
+
$(".tls-row").hide();
|
|
58
|
+
}
|
|
59
|
+
// Update default port
|
|
60
|
+
if (proto === "tcp" || proto === "tcps") {
|
|
61
|
+
if ($("#node-config-input-port").val() === "9000") {
|
|
62
|
+
$("#node-config-input-port").val("9009");
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
if ($("#node-config-input-port").val() === "9009") {
|
|
66
|
+
$("#node-config-input-port").val("9000");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
$("#node-config-input-protocol").trigger("change");
|
|
71
|
+
|
|
72
|
+
// Show/hide auth options
|
|
73
|
+
$("#node-config-input-useAuth").on("change", function() {
|
|
74
|
+
if ($(this).is(":checked")) {
|
|
75
|
+
$(".auth-type-row").show();
|
|
76
|
+
$("#node-config-input-authType").trigger("change");
|
|
77
|
+
} else {
|
|
78
|
+
$(".auth-type-row").hide();
|
|
79
|
+
$(".auth-row").hide();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
$("#node-config-input-useAuth").trigger("change");
|
|
83
|
+
|
|
84
|
+
// Show/hide auth fields based on auth type
|
|
85
|
+
$("#node-config-input-authType").on("change", function() {
|
|
86
|
+
var authType = $(this).val();
|
|
87
|
+
if (authType === "basic") {
|
|
88
|
+
$(".basic-auth-row").show();
|
|
89
|
+
$(".token-auth-row").hide();
|
|
90
|
+
} else {
|
|
91
|
+
$(".basic-auth-row").hide();
|
|
92
|
+
$(".token-auth-row").show();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
$("#node-config-input-authType").trigger("change");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<script type="text/html" data-template-name="questdb-config">
|
|
101
|
+
<div class="form-row">
|
|
102
|
+
<ul style="min-width: 450px; margin-bottom: 20px;" id="questdb-config-tabs"></ul>
|
|
103
|
+
</div>
|
|
104
|
+
<div id="questdb-config-tabs-content">
|
|
105
|
+
<div id="questdb-tab-connection" style="display:block;">
|
|
106
|
+
<div class="form-row">
|
|
107
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
108
|
+
<input type="text" id="node-config-input-name" placeholder="QuestDB Server">
|
|
109
|
+
</div>
|
|
110
|
+
<div class="form-row">
|
|
111
|
+
<label for="node-config-input-protocol"><i class="fa fa-exchange"></i> Protocol</label>
|
|
112
|
+
<select id="node-config-input-protocol" style="width:70%;">
|
|
113
|
+
<option value="http">HTTP</option>
|
|
114
|
+
<option value="https">HTTPS (TLS)</option>
|
|
115
|
+
<option value="tcp">TCP</option>
|
|
116
|
+
<option value="tcps">TCPS (TLS)</option>
|
|
117
|
+
</select>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="form-row">
|
|
120
|
+
<label for="node-config-input-host"><i class="fa fa-server"></i> Host</label>
|
|
121
|
+
<input type="text" id="node-config-input-host" placeholder="localhost or IP address">
|
|
122
|
+
</div>
|
|
123
|
+
<div class="form-row">
|
|
124
|
+
<label for="node-config-input-port"><i class="fa fa-plug"></i> Port</label>
|
|
125
|
+
<input type="text" id="node-config-input-port" placeholder="9000">
|
|
126
|
+
</div>
|
|
127
|
+
<div class="form-row tls-row">
|
|
128
|
+
<label for="node-config-input-tlsVerify"><i class="fa fa-certificate"></i> Verify TLS</label>
|
|
129
|
+
<input type="checkbox" id="node-config-input-tlsVerify" style="display:inline-block; width:auto; vertical-align:baseline;" checked>
|
|
130
|
+
<span style="margin-left:8px;">Verify server certificate</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="form-row tls-row">
|
|
133
|
+
<label for="node-config-input-tlsCa"><i class="fa fa-file-text-o"></i> CA Cert Path</label>
|
|
134
|
+
<input type="text" id="node-config-input-tlsCa" placeholder="Path to CA certificate (PEM)">
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<div id="questdb-tab-security" style="display:none;">
|
|
138
|
+
<div class="form-row">
|
|
139
|
+
<label for="node-config-input-useAuth"><i class="fa fa-lock"></i> Enable Auth</label>
|
|
140
|
+
<input type="checkbox" id="node-config-input-useAuth" style="display:inline-block; width:auto; vertical-align:baseline;">
|
|
141
|
+
<span style="margin-left:8px;">Enable authentication</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="form-row auth-type-row">
|
|
144
|
+
<label for="node-config-input-authType"><i class="fa fa-id-card"></i> Auth Type</label>
|
|
145
|
+
<select id="node-config-input-authType" style="width:70%;">
|
|
146
|
+
<option value="basic">Username / Password</option>
|
|
147
|
+
<option value="token">Bearer Token</option>
|
|
148
|
+
</select>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="form-row auth-row basic-auth-row">
|
|
151
|
+
<label for="node-config-input-username"><i class="fa fa-user"></i> Username</label>
|
|
152
|
+
<input type="text" id="node-config-input-username" placeholder="Username">
|
|
153
|
+
</div>
|
|
154
|
+
<div class="form-row auth-row basic-auth-row">
|
|
155
|
+
<label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
|
|
156
|
+
<input type="password" id="node-config-input-password" placeholder="Password">
|
|
157
|
+
</div>
|
|
158
|
+
<div class="form-row auth-row token-auth-row">
|
|
159
|
+
<label for="node-config-input-token"><i class="fa fa-ticket"></i> Token</label>
|
|
160
|
+
<input type="password" id="node-config-input-token" placeholder="Bearer token">
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div id="questdb-tab-advanced" style="display:none;">
|
|
164
|
+
<div class="form-row">
|
|
165
|
+
<label for="node-config-input-autoFlush"><i class="fa fa-refresh"></i> Auto Flush</label>
|
|
166
|
+
<input type="checkbox" id="node-config-input-autoFlush" style="display:inline-block; width:auto; vertical-align:baseline;" checked>
|
|
167
|
+
<span style="margin-left:8px;">Enable automatic flushing</span>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="form-row">
|
|
170
|
+
<label for="node-config-input-autoFlushRows"><i class="fa fa-bars"></i> Flush Rows</label>
|
|
171
|
+
<input type="text" id="node-config-input-autoFlushRows" placeholder="75000">
|
|
172
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">Rows before auto-flush</span>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="form-row">
|
|
175
|
+
<label for="node-config-input-autoFlushInterval"><i class="fa fa-clock-o"></i> Flush Interval</label>
|
|
176
|
+
<input type="text" id="node-config-input-autoFlushInterval" placeholder="1000">
|
|
177
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="form-row">
|
|
180
|
+
<label for="node-config-input-requestTimeout"><i class="fa fa-hourglass"></i> Request Timeout</label>
|
|
181
|
+
<input type="text" id="node-config-input-requestTimeout" placeholder="10000">
|
|
182
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="form-row">
|
|
185
|
+
<label for="node-config-input-retryTimeout"><i class="fa fa-repeat"></i> Retry Timeout</label>
|
|
186
|
+
<input type="text" id="node-config-input-retryTimeout" placeholder="10000">
|
|
187
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">ms</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="form-row">
|
|
190
|
+
<label for="node-config-input-initBufSize"><i class="fa fa-database"></i> Init Buffer</label>
|
|
191
|
+
<input type="text" id="node-config-input-initBufSize" placeholder="65536">
|
|
192
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">bytes (64 KiB)</span>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="form-row">
|
|
195
|
+
<label for="node-config-input-maxBufSize"><i class="fa fa-database"></i> Max Buffer</label>
|
|
196
|
+
<input type="text" id="node-config-input-maxBufSize" placeholder="104857600">
|
|
197
|
+
<span style="margin-left:8px; font-size:0.9em; color:#888;">bytes (100 MiB)</span>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<script type="text/javascript">
|
|
204
|
+
// Main QuestDB node
|
|
205
|
+
RED.nodes.registerType('questdb',{
|
|
206
|
+
category: 'questdb',
|
|
207
|
+
color: '#a23154',
|
|
208
|
+
defaults: {
|
|
209
|
+
name: {value:""},
|
|
210
|
+
questdb: {value:"", type:"questdb-config", required:true},
|
|
211
|
+
autoFlush: {value:true},
|
|
212
|
+
flushInterval: {value:1000, validate:RED.validators.number()}
|
|
213
|
+
},
|
|
214
|
+
inputs:1,
|
|
215
|
+
outputs:1,
|
|
216
|
+
icon: "font-awesome/fa-database",
|
|
217
|
+
label: function() {
|
|
218
|
+
return this.name || "Write";
|
|
219
|
+
},
|
|
220
|
+
paletteLabel: "Write",
|
|
221
|
+
labelStyle: function() {
|
|
222
|
+
return (this.name ? "node_label_italic" : "") + " questdb-white-text";
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
</script>
|
|
226
|
+
|
|
227
|
+
<style>
|
|
228
|
+
/* White text on flow canvas */
|
|
229
|
+
.questdb-white-text {
|
|
230
|
+
fill: #ffffff !important;
|
|
231
|
+
}
|
|
232
|
+
/* White text in palette */
|
|
233
|
+
.red-ui-palette-node[data-palette-type="questdb"] .red-ui-palette-label {
|
|
234
|
+
color: #ffffff !important;
|
|
235
|
+
}
|
|
236
|
+
</style>
|
|
237
|
+
|
|
238
|
+
<script type="text/html" data-template-name="questdb">
|
|
239
|
+
<div class="form-row">
|
|
240
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
241
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
242
|
+
</div>
|
|
243
|
+
<div class="form-row">
|
|
244
|
+
<label for="node-input-questdb"><i class="fa fa-server"></i> Server</label>
|
|
245
|
+
<input type="text" id="node-input-questdb" placeholder="QuestDB Server">
|
|
246
|
+
</div>
|
|
247
|
+
<div class="form-row">
|
|
248
|
+
<label for="node-input-autoFlush"><i class="fa fa-refresh"></i> Auto Flush</label>
|
|
249
|
+
<input type="checkbox" id="node-input-autoFlush" style="display:inline-block; width:auto; vertical-align:baseline;">
|
|
250
|
+
</div>
|
|
251
|
+
<div class="form-row">
|
|
252
|
+
<label for="node-input-flushInterval"><i class="fa fa-clock-o"></i> Flush Interval (ms)</label>
|
|
253
|
+
<input type="text" id="node-input-flushInterval" placeholder="1000">
|
|
254
|
+
</div>
|
|
255
|
+
</script>
|
|
256
|
+
|
|
257
|
+
<script type="text/html" data-help-name="questdb">
|
|
258
|
+
<p>Writes data to QuestDB using ILP protocol.</p>
|
|
259
|
+
|
|
260
|
+
<h3>Inputs</h3>
|
|
261
|
+
<dl class="message-properties">
|
|
262
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
263
|
+
<dd>Table name to write to (required)</dd>
|
|
264
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
265
|
+
<dd>Data to write:
|
|
266
|
+
<ul>
|
|
267
|
+
<li><code>symbols</code> - Tag columns (indexed)</li>
|
|
268
|
+
<li><code>columns</code> - Value columns</li>
|
|
269
|
+
<li><code>timestamp</code> - Optional (Date, number, or string)</li>
|
|
270
|
+
</ul>
|
|
271
|
+
</dd>
|
|
272
|
+
</dl>
|
|
273
|
+
|
|
274
|
+
<h3>Outputs</h3>
|
|
275
|
+
<dl class="message-properties">
|
|
276
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
277
|
+
<dd><code>{ success: true/false, table: "name" }</code></dd>
|
|
278
|
+
</dl>
|
|
279
|
+
|
|
280
|
+
<h3>Example</h3>
|
|
281
|
+
<pre>{
|
|
282
|
+
topic: "history_float",
|
|
283
|
+
payload: {
|
|
284
|
+
symbols: { tag_name: "temp1" },
|
|
285
|
+
columns: { value: 23.5 },
|
|
286
|
+
timestamp: new Date()
|
|
287
|
+
}
|
|
288
|
+
}</pre>
|
|
289
|
+
</script>
|
package/nodes/write.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
const { Sender } = require('@questdb/nodejs-client');
|
|
3
|
+
|
|
4
|
+
// Shared connection pool for config nodes
|
|
5
|
+
const connectionPool = new Map();
|
|
6
|
+
|
|
7
|
+
// Build connection string from config
|
|
8
|
+
function buildConnectionString(configNode) {
|
|
9
|
+
const protocol = configNode.protocol || 'http';
|
|
10
|
+
let connStr = `${protocol}::addr=${configNode.host}:${configNode.port};`;
|
|
11
|
+
|
|
12
|
+
// Authentication
|
|
13
|
+
if (configNode.useAuth) {
|
|
14
|
+
if (configNode.authType === 'token' && configNode.token) {
|
|
15
|
+
connStr += `token=${configNode.token};`;
|
|
16
|
+
} else if (configNode.username && configNode.password) {
|
|
17
|
+
connStr += `username=${configNode.username};password=${configNode.password};`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// TLS options (for https/tcps)
|
|
22
|
+
if (protocol === 'https' || protocol === 'tcps') {
|
|
23
|
+
if (!configNode.tlsVerify) {
|
|
24
|
+
connStr += `tls_verify=unsafe_off;`;
|
|
25
|
+
}
|
|
26
|
+
if (configNode.tlsCa) {
|
|
27
|
+
connStr += `tls_ca=${configNode.tlsCa};`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Auto-flush options
|
|
32
|
+
if (configNode.autoFlush === false) {
|
|
33
|
+
connStr += `auto_flush=off;`;
|
|
34
|
+
} else {
|
|
35
|
+
if (configNode.autoFlushRows) {
|
|
36
|
+
connStr += `auto_flush_rows=${configNode.autoFlushRows};`;
|
|
37
|
+
}
|
|
38
|
+
if (configNode.autoFlushInterval) {
|
|
39
|
+
connStr += `auto_flush_interval=${configNode.autoFlushInterval};`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Buffer options
|
|
44
|
+
if (configNode.initBufSize) {
|
|
45
|
+
connStr += `init_buf_size=${configNode.initBufSize};`;
|
|
46
|
+
}
|
|
47
|
+
if (configNode.maxBufSize) {
|
|
48
|
+
connStr += `max_buf_size=${configNode.maxBufSize};`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// HTTP options
|
|
52
|
+
if (protocol === 'http' || protocol === 'https') {
|
|
53
|
+
if (configNode.requestTimeout) {
|
|
54
|
+
connStr += `request_timeout=${configNode.requestTimeout};`;
|
|
55
|
+
}
|
|
56
|
+
if (configNode.retryTimeout) {
|
|
57
|
+
connStr += `retry_timeout=${configNode.retryTimeout};`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return connStr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Configuration node for QuestDB connection
|
|
65
|
+
function QuestDBConfigNode(config) {
|
|
66
|
+
RED.nodes.createNode(this, config);
|
|
67
|
+
const configNode = this;
|
|
68
|
+
|
|
69
|
+
// Connection settings
|
|
70
|
+
configNode.protocol = config.protocol || 'http';
|
|
71
|
+
configNode.host = config.host;
|
|
72
|
+
configNode.port = parseInt(config.port) || (configNode.protocol.startsWith('tcp') ? 9009 : 9000);
|
|
73
|
+
configNode.name = config.name;
|
|
74
|
+
|
|
75
|
+
// TLS settings
|
|
76
|
+
configNode.tlsVerify = config.tlsVerify !== false;
|
|
77
|
+
configNode.tlsCa = config.tlsCa || '';
|
|
78
|
+
|
|
79
|
+
// Auth settings
|
|
80
|
+
configNode.useAuth = config.useAuth || false;
|
|
81
|
+
configNode.authType = config.authType || 'basic';
|
|
82
|
+
configNode.username = this.credentials ? this.credentials.username : '';
|
|
83
|
+
configNode.password = this.credentials ? this.credentials.password : '';
|
|
84
|
+
configNode.token = this.credentials ? this.credentials.token : '';
|
|
85
|
+
|
|
86
|
+
// Auto-flush settings
|
|
87
|
+
configNode.autoFlush = config.autoFlush !== false;
|
|
88
|
+
configNode.autoFlushRows = parseInt(config.autoFlushRows) || 75000;
|
|
89
|
+
configNode.autoFlushInterval = parseInt(config.autoFlushInterval) || 1000;
|
|
90
|
+
|
|
91
|
+
// Buffer settings
|
|
92
|
+
configNode.initBufSize = parseInt(config.initBufSize) || 65536;
|
|
93
|
+
configNode.maxBufSize = parseInt(config.maxBufSize) || 104857600;
|
|
94
|
+
|
|
95
|
+
// HTTP settings
|
|
96
|
+
configNode.requestTimeout = parseInt(config.requestTimeout) || 10000;
|
|
97
|
+
configNode.retryTimeout = parseInt(config.retryTimeout) || 10000;
|
|
98
|
+
|
|
99
|
+
const connectionKey = `${configNode.protocol}://${configNode.host}:${configNode.port}`;
|
|
100
|
+
|
|
101
|
+
// Initialize shared connection if not exists
|
|
102
|
+
if (!connectionPool.has(connectionKey)) {
|
|
103
|
+
const connectionState = {
|
|
104
|
+
sender: null,
|
|
105
|
+
connected: false,
|
|
106
|
+
connecting: false,
|
|
107
|
+
users: 0,
|
|
108
|
+
reconnectTimer: null
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
connectionState.connect = async function() {
|
|
112
|
+
if (connectionState.connecting || connectionState.connected) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
connectionState.connecting = true;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Validate configuration
|
|
120
|
+
if (!configNode.host || isNaN(configNode.port)) {
|
|
121
|
+
throw new Error(`Invalid configuration: host=${configNode.host}, port=${configNode.port}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const connStr = buildConnectionString(configNode);
|
|
125
|
+
RED.log.info(`[QuestDB] Connecting with: ${connStr.replace(/password=[^;]+/, 'password=***').replace(/token=[^;]+/, 'token=***')}`);
|
|
126
|
+
|
|
127
|
+
connectionState.sender = Sender.fromConfig(connStr);
|
|
128
|
+
connectionState.connected = true;
|
|
129
|
+
connectionState.connecting = false;
|
|
130
|
+
|
|
131
|
+
RED.log.info(`[QuestDB] Connected to ${configNode.protocol}://${configNode.host}:${configNode.port}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
connectionState.connected = false;
|
|
134
|
+
connectionState.connecting = false;
|
|
135
|
+
RED.log.error(`[QuestDB] Failed to connect to ${configNode.host}:${configNode.port}: ${err.message}`);
|
|
136
|
+
|
|
137
|
+
// Schedule reconnection
|
|
138
|
+
if (!connectionState.reconnectTimer) {
|
|
139
|
+
connectionState.reconnectTimer = setTimeout(() => {
|
|
140
|
+
connectionState.reconnectTimer = null;
|
|
141
|
+
if (connectionState.users > 0) {
|
|
142
|
+
connectionState.connect();
|
|
143
|
+
}
|
|
144
|
+
}, 5000); // Retry in 5 seconds
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
connectionState.disconnect = async function() {
|
|
150
|
+
if (connectionState.reconnectTimer) {
|
|
151
|
+
clearTimeout(connectionState.reconnectTimer);
|
|
152
|
+
connectionState.reconnectTimer = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (connectionState.sender) {
|
|
156
|
+
try {
|
|
157
|
+
await connectionState.sender.flush();
|
|
158
|
+
await connectionState.sender.close();
|
|
159
|
+
RED.log.info(`[QuestDB] Disconnected from ${configNode.host}:${configNode.port}`);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
RED.log.error(`[QuestDB] Error closing connection: ${err.message}`);
|
|
162
|
+
}
|
|
163
|
+
connectionState.sender = null;
|
|
164
|
+
}
|
|
165
|
+
connectionState.connected = false;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
connectionPool.set(connectionKey, connectionState);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
configNode.getConnection = function() {
|
|
172
|
+
return connectionPool.get(connectionKey);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
configNode.on('close', async function(done) {
|
|
176
|
+
const conn = connectionPool.get(connectionKey);
|
|
177
|
+
if (conn) {
|
|
178
|
+
conn.users--;
|
|
179
|
+
if (conn.users <= 0) {
|
|
180
|
+
await conn.disconnect();
|
|
181
|
+
connectionPool.delete(connectionKey);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
done();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
RED.nodes.registerType("questdb-config", QuestDBConfigNode, {
|
|
188
|
+
credentials: {
|
|
189
|
+
username: {type: "text"},
|
|
190
|
+
password: {type: "password"},
|
|
191
|
+
token: {type: "password"}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Main QuestDB node
|
|
196
|
+
function QuestDBNode(config) {
|
|
197
|
+
RED.nodes.createNode(this, config);
|
|
198
|
+
const node = this;
|
|
199
|
+
|
|
200
|
+
// Get configuration from config node
|
|
201
|
+
node.questdbConfig = RED.nodes.getNode(config.questdb);
|
|
202
|
+
|
|
203
|
+
if (!node.questdbConfig) {
|
|
204
|
+
node.error("QuestDB configuration not set");
|
|
205
|
+
node.status({fill:"red", shape:"ring", text:"no config"});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Configuration from the node properties
|
|
210
|
+
node.autoFlush = config.autoFlush !== false; // default true
|
|
211
|
+
node.flushInterval = config.flushInterval || 1000;
|
|
212
|
+
|
|
213
|
+
// Get shared connection
|
|
214
|
+
const connection = node.questdbConfig.getConnection();
|
|
215
|
+
if (!connection) {
|
|
216
|
+
node.error("Failed to get connection from config");
|
|
217
|
+
node.status({fill:"red", shape:"ring", text:"no connection"});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
connection.users++;
|
|
222
|
+
|
|
223
|
+
// Connect if not already connected
|
|
224
|
+
if (!connection.connected && !connection.connecting) {
|
|
225
|
+
connection.connect();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Update status based on connection state
|
|
229
|
+
function updateStatus() {
|
|
230
|
+
if (connection.connected) {
|
|
231
|
+
node.status({fill:"green", shape:"dot", text:"connected"});
|
|
232
|
+
} else if (connection.connecting) {
|
|
233
|
+
node.status({fill:"yellow", shape:"ring", text:"connecting"});
|
|
234
|
+
} else {
|
|
235
|
+
node.status({fill:"red", shape:"ring", text:"disconnected"});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
updateStatus();
|
|
240
|
+
|
|
241
|
+
// Periodic status check
|
|
242
|
+
const statusInterval = setInterval(updateStatus, 2000);
|
|
243
|
+
|
|
244
|
+
node.on('input', async function(msg) {
|
|
245
|
+
// Auto-reconnect if disconnected
|
|
246
|
+
if (!connection.connected && !connection.connecting) {
|
|
247
|
+
connection.connect();
|
|
248
|
+
node.error("Not connected to QuestDB, reconnecting...", msg);
|
|
249
|
+
updateStatus();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!connection.connected || !connection.sender) {
|
|
254
|
+
node.error("Not connected to QuestDB", msg);
|
|
255
|
+
updateStatus();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate message
|
|
260
|
+
const tableName = msg.topic;
|
|
261
|
+
if (!tableName) {
|
|
262
|
+
node.error("Topic (table name) not specified", msg);
|
|
263
|
+
node.status({fill:"red", shape:"ring", text:"no topic"});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const payload = msg.payload;
|
|
268
|
+
if (payload === undefined || payload === null) {
|
|
269
|
+
node.error("Payload is empty", msg);
|
|
270
|
+
node.status({fill:"red", shape:"ring", text:"no payload"});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
connection.sender.table(tableName);
|
|
276
|
+
|
|
277
|
+
// Check if simple format (msg.topic + numeric payload)
|
|
278
|
+
if (typeof payload === 'number' || (typeof payload === 'string' && !isNaN(parseFloat(payload)))) {
|
|
279
|
+
// Simple format: use msg.topic as tag, payload as value
|
|
280
|
+
var tag = msg.topic || 'default';
|
|
281
|
+
var value = typeof payload === 'number' ? payload : parseFloat(payload);
|
|
282
|
+
connection.sender.symbol('tag_name', tag);
|
|
283
|
+
connection.sender.floatColumn('value', value);
|
|
284
|
+
|
|
285
|
+
// Handle timestamp
|
|
286
|
+
if (msg.timestamp) {
|
|
287
|
+
var ts = typeof msg.timestamp === 'number' ? msg.timestamp : Date.now();
|
|
288
|
+
connection.sender.at(BigInt(ts) * 1000n);
|
|
289
|
+
} else {
|
|
290
|
+
connection.sender.atNow();
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
// QuestDB format: payload.symbols + payload.columns
|
|
294
|
+
if (payload.symbols && typeof payload.symbols === 'object') {
|
|
295
|
+
for (const [key, value] of Object.entries(payload.symbols)) {
|
|
296
|
+
if (value === null || value === undefined) continue;
|
|
297
|
+
connection.sender.symbol(String(key), String(value));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (payload.columns && typeof payload.columns === 'object') {
|
|
302
|
+
for (const [key, value] of Object.entries(payload.columns)) {
|
|
303
|
+
if (value === null || value === undefined) continue;
|
|
304
|
+
if (typeof value === 'number') {
|
|
305
|
+
if (!isFinite(value)) {
|
|
306
|
+
node.warn(`Skipping non-finite number for column '${key}'`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
connection.sender.floatColumn(key, value);
|
|
310
|
+
} else if (typeof value === 'boolean') {
|
|
311
|
+
connection.sender.booleanColumn(key, value);
|
|
312
|
+
} else if (typeof value === 'string') {
|
|
313
|
+
// Check if it's an ISO date string
|
|
314
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
|
|
315
|
+
const dateValue = new Date(value);
|
|
316
|
+
if (!isNaN(dateValue.getTime())) {
|
|
317
|
+
// Convert to microseconds for QuestDB
|
|
318
|
+
const microSeconds = BigInt(dateValue.getTime()) * 1000n;
|
|
319
|
+
connection.sender.timestampColumn(key, microSeconds);
|
|
320
|
+
} else {
|
|
321
|
+
connection.sender.stringColumn(key, value);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
connection.sender.stringColumn(key, value);
|
|
325
|
+
}
|
|
326
|
+
} else if (value instanceof Date) {
|
|
327
|
+
// Convert to microseconds for QuestDB
|
|
328
|
+
const microSeconds = BigInt(value.getTime()) * 1000n;
|
|
329
|
+
connection.sender.timestampColumn(key, microSeconds);
|
|
330
|
+
} else {
|
|
331
|
+
node.warn(`Skipping unsupported type '${typeof value}' for column '${key}'`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} else if (!payload.symbols) {
|
|
335
|
+
node.error("Payload must have 'symbols' and/or 'columns' properties, or be a number", msg);
|
|
336
|
+
node.status({fill:"red", shape:"ring", text:"bad format"});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (payload.timestamp) {
|
|
341
|
+
let timestampMicros;
|
|
342
|
+
if (payload.timestamp instanceof Date) {
|
|
343
|
+
timestampMicros = BigInt(payload.timestamp.getTime()) * 1000n;
|
|
344
|
+
} else if (typeof payload.timestamp === 'number') {
|
|
345
|
+
timestampMicros = BigInt(payload.timestamp) * 1000n;
|
|
346
|
+
} else if (typeof payload.timestamp === 'bigint') {
|
|
347
|
+
timestampMicros = payload.timestamp;
|
|
348
|
+
} else if (typeof payload.timestamp === 'string') {
|
|
349
|
+
const parsed = Date.parse(payload.timestamp);
|
|
350
|
+
if (isNaN(parsed)) {
|
|
351
|
+
node.error(`Invalid timestamp string: ${payload.timestamp}`);
|
|
352
|
+
connection.sender.atNow(); // Complete the row to avoid buffer corruption
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
timestampMicros = BigInt(parsed) * 1000n;
|
|
356
|
+
} else {
|
|
357
|
+
node.error(`Invalid timestamp type: ${typeof payload.timestamp}`);
|
|
358
|
+
connection.sender.atNow(); // Complete the row to avoid buffer corruption
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
connection.sender.at(timestampMicros);
|
|
362
|
+
} else {
|
|
363
|
+
connection.sender.atNow();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (node.autoFlush) {
|
|
368
|
+
await connection.sender.flush();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
node.status({fill:"green", shape:"dot", text:`sent: ${tableName}`});
|
|
372
|
+
msg.payload = { success: true, table: tableName };
|
|
373
|
+
node.send(msg);
|
|
374
|
+
|
|
375
|
+
} catch (err) {
|
|
376
|
+
const errMsg = err.message || String(err);
|
|
377
|
+
|
|
378
|
+
node.warn(`QuestDB write failed: ${errMsg}`);
|
|
379
|
+
node.status({fill:"yellow", shape:"ring", text:"write failed"});
|
|
380
|
+
|
|
381
|
+
// Recreate sender to clear bad state
|
|
382
|
+
try {
|
|
383
|
+
const connStr = buildConnectionString(node.questdbConfig);
|
|
384
|
+
connection.sender = Sender.fromConfig(connStr);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
connection.connected = false;
|
|
387
|
+
connection.connect();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
msg.payload = { success: false, error: errMsg };
|
|
391
|
+
node.send(msg);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
node.on('close', function(done) {
|
|
396
|
+
clearInterval(statusInterval);
|
|
397
|
+
connection.users--;
|
|
398
|
+
done();
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
RED.nodes.registerType("questdb", QuestDBNode);
|
|
403
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-questdb",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node-RED nodes for QuestDB time-series database",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"questdb",
|
|
8
|
+
"time-series",
|
|
9
|
+
"database"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": ""
|
|
15
|
+
},
|
|
16
|
+
"node-red": {
|
|
17
|
+
"version": ">=2.0.0",
|
|
18
|
+
"nodes": {
|
|
19
|
+
"questdb-write": "nodes/write.js",
|
|
20
|
+
"questdb-mapper": "nodes/mapper.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=14.0.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepublishOnly": "npm test --if-present",
|
|
28
|
+
"publish:dry": "npm publish --dry-run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@questdb/nodejs-client": "^3.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|