node-red-contrib-influxdb3 1.0.6 → 1.0.7
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 +6 -5
- package/__tests__/influxdb3.test.js +328 -6
- package/influxdb3.html +2 -2
- package/influxdb3.js +53 -43
- package/lib/line-protocol.js +41 -0
- package/package.json +6 -2
- package/renovate.json +9 -1
- package/test/line-protocol-validation.test.js +3 -25
- package/test/point-api.test.js +43 -30
package/README.md
CHANGED
|
@@ -52,8 +52,8 @@ A configuration node that stores connection details for your InfluxDB v3 instanc
|
|
|
52
52
|
- **Host**: Your InfluxDB v3 host URL (e.g., `https://us-east-1-1.aws.cloud2.influxdata.com`)
|
|
53
53
|
- **Token**: Your InfluxDB v3 authentication token
|
|
54
54
|
- **Database**: The default database (bucket) name
|
|
55
|
-
- **Verify TLS**: Toggle TLS certificate verification (unchecked
|
|
56
|
-
- **CA Cert Path**: Optional filesystem path
|
|
55
|
+
- **Verify TLS**: Toggle TLS certificate verification for this connection (unchecked disables verification for this connection only)
|
|
56
|
+
- **CA Cert Path**: Optional filesystem path to a PEM CA certificate used to verify this connection's TLS certificate
|
|
57
57
|
|
|
58
58
|
### InfluxDB v3 Write Node
|
|
59
59
|
|
|
@@ -399,8 +399,8 @@ Simply reference them in the Node-RED UI using `${INFLUX_HOST}` syntax (if using
|
|
|
399
399
|
|
|
400
400
|
If you are connecting to a local InfluxDB v3 instance with a custom certificate:
|
|
401
401
|
|
|
402
|
-
- Set **CA Cert Path** in the config node to the PEM file containing your root CA.
|
|
403
|
-
- As a last resort, disable **Verify TLS
|
|
402
|
+
- Set **CA Cert Path** in the config node to the PEM file containing your root CA. The certificate is read when the connection is first used and applied to this connection only.
|
|
403
|
+
- As a last resort, disable **Verify TLS**. This skips certificate verification for this connection only (it does not affect other connections or the rest of the Node-RED process).
|
|
404
404
|
|
|
405
405
|
### Data Not Appearing
|
|
406
406
|
|
|
@@ -416,7 +416,8 @@ The node will display error status and log details to the Node-RED debug panel:
|
|
|
416
416
|
|
|
417
417
|
## Requirements
|
|
418
418
|
|
|
419
|
-
- Node
|
|
419
|
+
- Node.js v18.0.0 or higher
|
|
420
|
+
- Node-RED v3.0.0 or higher
|
|
420
421
|
- InfluxDB v3 instance (Cloud or Edge)
|
|
421
422
|
|
|
422
423
|
## License
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
2
4
|
|
|
3
5
|
let mockLastClientOptions;
|
|
4
6
|
let mockLastClientInstance;
|
|
@@ -135,8 +137,37 @@ describe('InfluxDB v3 config node', () => {
|
|
|
135
137
|
expect(options.token).toBe('token');
|
|
136
138
|
});
|
|
137
139
|
|
|
138
|
-
test('
|
|
139
|
-
const
|
|
140
|
+
test('passes CA certificate via transportOptions and does not touch global env', () => {
|
|
141
|
+
const originalEnv = process.env.NODE_EXTRA_CA_CERTS;
|
|
142
|
+
const caPath = path.join(os.tmpdir(), `influx-ca-${Date.now()}.pem`);
|
|
143
|
+
const caContents = '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n';
|
|
144
|
+
fs.writeFileSync(caPath, caContents);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const { RED, influxModule } = setup();
|
|
148
|
+
const ConfigCtor = RED._types['influxdb3-config'];
|
|
149
|
+
|
|
150
|
+
const configNode = new ConfigCtor({
|
|
151
|
+
host: 'https://example.com',
|
|
152
|
+
database: 'metrics',
|
|
153
|
+
name: 'Test',
|
|
154
|
+
caCertPath: caPath,
|
|
155
|
+
credentials: { token: 'token' }
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
configNode.getClient();
|
|
159
|
+
|
|
160
|
+
const options = influxModule.__getLastClientOptions();
|
|
161
|
+
expect(options.transportOptions).toBeDefined();
|
|
162
|
+
expect(options.transportOptions.ca.toString()).toBe(caContents);
|
|
163
|
+
// Global env must be left untouched
|
|
164
|
+
expect(process.env.NODE_EXTRA_CA_CERTS).toBe(originalEnv);
|
|
165
|
+
} finally {
|
|
166
|
+
fs.unlinkSync(caPath);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('throws a clear error when the CA certificate cannot be read', () => {
|
|
140
171
|
const { RED } = setup();
|
|
141
172
|
const ConfigCtor = RED._types['influxdb3-config'];
|
|
142
173
|
|
|
@@ -144,14 +175,50 @@ describe('InfluxDB v3 config node', () => {
|
|
|
144
175
|
host: 'https://example.com',
|
|
145
176
|
database: 'metrics',
|
|
146
177
|
name: 'Test',
|
|
147
|
-
caCertPath: path.join(
|
|
178
|
+
caCertPath: path.join(os.tmpdir(), 'does-not-exist-influx-ca.pem'),
|
|
179
|
+
credentials: { token: 'token' }
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(() => configNode.getClient()).toThrow(/Failed to read CA certificate/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('disables TLS verification via transportOptions without setting global env', () => {
|
|
186
|
+
const originalEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
187
|
+
const { RED, influxModule } = setup();
|
|
188
|
+
const ConfigCtor = RED._types['influxdb3-config'];
|
|
189
|
+
|
|
190
|
+
const configNode = new ConfigCtor({
|
|
191
|
+
host: 'https://example.com',
|
|
192
|
+
database: 'metrics',
|
|
193
|
+
name: 'Test',
|
|
194
|
+
tlsRejectUnauthorized: false,
|
|
195
|
+
credentials: { token: 'token' }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
configNode.getClient();
|
|
199
|
+
|
|
200
|
+
const options = influxModule.__getLastClientOptions();
|
|
201
|
+
expect(options.transportOptions).toBeDefined();
|
|
202
|
+
expect(options.transportOptions.rejectUnauthorized).toBe(false);
|
|
203
|
+
// Global env must be left untouched
|
|
204
|
+
expect(process.env.NODE_TLS_REJECT_UNAUTHORIZED).toBe(originalEnv);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('omits transportOptions entirely when TLS defaults are used', () => {
|
|
208
|
+
const { RED, influxModule } = setup();
|
|
209
|
+
const ConfigCtor = RED._types['influxdb3-config'];
|
|
210
|
+
|
|
211
|
+
const configNode = new ConfigCtor({
|
|
212
|
+
host: 'https://example.com',
|
|
213
|
+
database: 'metrics',
|
|
214
|
+
name: 'Test',
|
|
148
215
|
credentials: { token: 'token' }
|
|
149
216
|
});
|
|
150
217
|
|
|
151
218
|
configNode.getClient();
|
|
152
219
|
|
|
153
|
-
|
|
154
|
-
|
|
220
|
+
const options = influxModule.__getLastClientOptions();
|
|
221
|
+
expect(options.transportOptions).toBeUndefined();
|
|
155
222
|
});
|
|
156
223
|
});
|
|
157
224
|
|
|
@@ -379,6 +446,27 @@ describe('addFieldToPoint – field type handling', () => {
|
|
|
379
446
|
expect.stringContaining("marked as integer but value is 3.7")
|
|
380
447
|
);
|
|
381
448
|
});
|
|
449
|
+
|
|
450
|
+
test('negative non-integer float truncates toward zero (not floor)', async () => {
|
|
451
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
452
|
+
const msg = {
|
|
453
|
+
measurement: 'sensor',
|
|
454
|
+
payload: {
|
|
455
|
+
fields: { value: -3.7 },
|
|
456
|
+
integers: ['value']
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const send = jest.fn();
|
|
460
|
+
const done = jest.fn();
|
|
461
|
+
await writeNode._handlers.input(msg, send, done);
|
|
462
|
+
|
|
463
|
+
const point = influxModule.__getLastPoint();
|
|
464
|
+
// Math.trunc(-3.7) === -3 (toward zero), not Math.floor's -4
|
|
465
|
+
expect(point.integerFields.value).toBe(-3);
|
|
466
|
+
expect(writeNode.warn).toHaveBeenCalledWith(
|
|
467
|
+
expect.stringContaining("truncated to -3")
|
|
468
|
+
);
|
|
469
|
+
});
|
|
382
470
|
});
|
|
383
471
|
|
|
384
472
|
describe('addFieldToPoint – enhanced error messages (issue #16)', () => {
|
|
@@ -604,6 +692,91 @@ describe('buildLineProtocol – simplified payload format', () => {
|
|
|
604
692
|
});
|
|
605
693
|
});
|
|
606
694
|
|
|
695
|
+
describe('buildLineProtocol – tag value handling', () => {
|
|
696
|
+
test('string, number and boolean tag values are coerced to strings', async () => {
|
|
697
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
698
|
+
const msg = {
|
|
699
|
+
measurement: 'sensor',
|
|
700
|
+
payload: {
|
|
701
|
+
fields: { value: 1 },
|
|
702
|
+
tags: { location: 'lab', floor: 2, active: true }
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
const send = jest.fn();
|
|
706
|
+
const done = jest.fn();
|
|
707
|
+
await writeNode._handlers.input(msg, send, done);
|
|
708
|
+
|
|
709
|
+
const point = influxModule.__getLastPoint();
|
|
710
|
+
expect(point.tags.location).toBe('lab');
|
|
711
|
+
expect(point.tags.floor).toBe('2');
|
|
712
|
+
expect(point.tags.active).toBe('true');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('object tag value is skipped with a warning instead of "[object Object]"', async () => {
|
|
716
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
717
|
+
const msg = {
|
|
718
|
+
measurement: 'sensor',
|
|
719
|
+
payload: {
|
|
720
|
+
fields: { value: 1 },
|
|
721
|
+
tags: { good: 'ok', broken: { nested: true } }
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
const send = jest.fn();
|
|
725
|
+
const done = jest.fn();
|
|
726
|
+
await writeNode._handlers.input(msg, send, done);
|
|
727
|
+
|
|
728
|
+
const point = influxModule.__getLastPoint();
|
|
729
|
+
expect(point.tags.good).toBe('ok');
|
|
730
|
+
expect(point.tags.broken).toBeUndefined();
|
|
731
|
+
expect(writeNode.warn).toHaveBeenCalledWith(
|
|
732
|
+
expect.stringContaining("Skipping tag 'broken': unsupported type 'object' (Object)")
|
|
733
|
+
);
|
|
734
|
+
// The valid field still writes, so the message succeeds
|
|
735
|
+
expect(send).toHaveBeenCalled();
|
|
736
|
+
expect(done).toHaveBeenCalled();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('array tag value shows Array type name in warning and is skipped', async () => {
|
|
740
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
741
|
+
const msg = {
|
|
742
|
+
measurement: 'sensor',
|
|
743
|
+
payload: {
|
|
744
|
+
fields: { value: 1 },
|
|
745
|
+
tags: { list: [1, 2, 3] }
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const send = jest.fn();
|
|
749
|
+
const done = jest.fn();
|
|
750
|
+
await writeNode._handlers.input(msg, send, done);
|
|
751
|
+
|
|
752
|
+
const point = influxModule.__getLastPoint();
|
|
753
|
+
expect(point.tags.list).toBeUndefined();
|
|
754
|
+
expect(writeNode.warn).toHaveBeenCalledWith(
|
|
755
|
+
expect.stringContaining("Skipping tag 'list': unsupported type 'object' (Array)")
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('null and undefined tag values are skipped silently', async () => {
|
|
760
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
761
|
+
const msg = {
|
|
762
|
+
measurement: 'sensor',
|
|
763
|
+
payload: {
|
|
764
|
+
fields: { value: 1 },
|
|
765
|
+
tags: { keep: 'yes', gone: null, missing: undefined }
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
const send = jest.fn();
|
|
769
|
+
const done = jest.fn();
|
|
770
|
+
await writeNode._handlers.input(msg, send, done);
|
|
771
|
+
|
|
772
|
+
const point = influxModule.__getLastPoint();
|
|
773
|
+
expect(point.tags.keep).toBe('yes');
|
|
774
|
+
expect(point.tags.gone).toBeUndefined();
|
|
775
|
+
expect(point.tags.missing).toBeUndefined();
|
|
776
|
+
expect(writeNode.warn).not.toHaveBeenCalled();
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
607
780
|
describe('buildLineProtocol – timestamp handling', () => {
|
|
608
781
|
test('numeric timestamp from msg.payload.timestamp', async () => {
|
|
609
782
|
const { influxModule, writeNode } = createWriteNode();
|
|
@@ -867,7 +1040,7 @@ describe('Array payload support', () => {
|
|
|
867
1040
|
});
|
|
868
1041
|
|
|
869
1042
|
test('array item error includes item index', async () => {
|
|
870
|
-
const { RED
|
|
1043
|
+
const { RED } = setup();
|
|
871
1044
|
const ConfigCtor = RED._types['influxdb3-config'];
|
|
872
1045
|
const WriteCtor = RED._types['influxdb3-write'];
|
|
873
1046
|
|
|
@@ -937,6 +1110,155 @@ describe('Array payload support', () => {
|
|
|
937
1110
|
});
|
|
938
1111
|
});
|
|
939
1112
|
|
|
1113
|
+
describe('write node – client.write failure', () => {
|
|
1114
|
+
test('rejected write sets red status and calls done with the error', async () => {
|
|
1115
|
+
const { configNode, writeNode } = createWriteNode();
|
|
1116
|
+
|
|
1117
|
+
// Pre-create the cached client and make its write reject
|
|
1118
|
+
const client = configNode.getClient();
|
|
1119
|
+
client.write = jest.fn().mockRejectedValue(new Error('connection refused'));
|
|
1120
|
+
|
|
1121
|
+
const msg = {
|
|
1122
|
+
measurement: 'sensor',
|
|
1123
|
+
payload: { fields: { value: 1 } }
|
|
1124
|
+
};
|
|
1125
|
+
const send = jest.fn();
|
|
1126
|
+
const done = jest.fn();
|
|
1127
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1128
|
+
|
|
1129
|
+
expect(client.write).toHaveBeenCalled();
|
|
1130
|
+
expect(done).toHaveBeenCalledWith(expect.any(Error));
|
|
1131
|
+
expect(done.mock.calls[0][0].message).toBe('connection refused');
|
|
1132
|
+
// Error status is red and the message is NOT forwarded
|
|
1133
|
+
expect(writeNode.status).toHaveBeenCalledWith(
|
|
1134
|
+
expect.objectContaining({ fill: 'red', text: 'connection refused' })
|
|
1135
|
+
);
|
|
1136
|
+
expect(send).not.toHaveBeenCalled();
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test('long error messages are truncated in the node status', async () => {
|
|
1140
|
+
const { configNode, writeNode } = createWriteNode();
|
|
1141
|
+
const longMessage = 'x'.repeat(200);
|
|
1142
|
+
|
|
1143
|
+
const client = configNode.getClient();
|
|
1144
|
+
client.write = jest.fn().mockRejectedValue(new Error(longMessage));
|
|
1145
|
+
|
|
1146
|
+
const msg = { measurement: 'sensor', payload: { fields: { value: 1 } } };
|
|
1147
|
+
const send = jest.fn();
|
|
1148
|
+
const done = jest.fn();
|
|
1149
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1150
|
+
|
|
1151
|
+
const statusArg = writeNode.status.mock.calls.find(c => c[0] && c[0].fill === 'red')[0];
|
|
1152
|
+
expect(statusArg.text.length).toBeLessThanOrEqual(83); // 80 chars + '...'
|
|
1153
|
+
expect(statusArg.text.endsWith('...')).toBe(true);
|
|
1154
|
+
// done still receives the full, untruncated error
|
|
1155
|
+
expect(done.mock.calls[0][0].message).toBe(longMessage);
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
describe('config node – close()', () => {
|
|
1160
|
+
test('close() closes the cached client and clears the reference', () => {
|
|
1161
|
+
const { configNode } = createWriteNode();
|
|
1162
|
+
|
|
1163
|
+
const client = configNode.getClient();
|
|
1164
|
+
expect(configNode.client).toBe(client);
|
|
1165
|
+
|
|
1166
|
+
configNode._handlers.close();
|
|
1167
|
+
|
|
1168
|
+
expect(client.close).toHaveBeenCalled();
|
|
1169
|
+
expect(configNode.client).toBeNull();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
test('close() is a no-op when no client was ever created', () => {
|
|
1173
|
+
const { configNode } = createWriteNode();
|
|
1174
|
+
expect(configNode.client).toBeNull();
|
|
1175
|
+
expect(() => configNode._handlers.close()).not.toThrow();
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
describe('write node – measurement resolution', () => {
|
|
1180
|
+
test('empty msg.measurement falls back to the node default', async () => {
|
|
1181
|
+
const { influxModule, writeNode } = createWriteNode(); // node default: 'test_measurement'
|
|
1182
|
+
const msg = {
|
|
1183
|
+
measurement: '',
|
|
1184
|
+
payload: { fields: { value: 1 } }
|
|
1185
|
+
};
|
|
1186
|
+
const send = jest.fn();
|
|
1187
|
+
const done = jest.fn();
|
|
1188
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1189
|
+
|
|
1190
|
+
const point = influxModule.__getLastPoint();
|
|
1191
|
+
expect(point.measurement).toBe('test_measurement');
|
|
1192
|
+
expect(done).toHaveBeenCalled();
|
|
1193
|
+
expect(done.mock.calls[0][0]).toBeUndefined();
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
test('msg.measurement overrides the node default', async () => {
|
|
1197
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
1198
|
+
const msg = {
|
|
1199
|
+
measurement: 'override',
|
|
1200
|
+
payload: { fields: { value: 1 } }
|
|
1201
|
+
};
|
|
1202
|
+
const send = jest.fn();
|
|
1203
|
+
const done = jest.fn();
|
|
1204
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1205
|
+
|
|
1206
|
+
const point = influxModule.__getLastPoint();
|
|
1207
|
+
expect(point.measurement).toBe('override');
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
test('whitespace-only msg.measurement falls back to the node default', async () => {
|
|
1211
|
+
const { influxModule, writeNode } = createWriteNode(); // node default: 'test_measurement'
|
|
1212
|
+
const msg = {
|
|
1213
|
+
measurement: ' ',
|
|
1214
|
+
payload: { fields: { value: 1 } }
|
|
1215
|
+
};
|
|
1216
|
+
const send = jest.fn();
|
|
1217
|
+
const done = jest.fn();
|
|
1218
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1219
|
+
|
|
1220
|
+
const point = influxModule.__getLastPoint();
|
|
1221
|
+
expect(point.measurement).toBe('test_measurement');
|
|
1222
|
+
expect(done.mock.calls[0][0]).toBeUndefined();
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
test('a provided measurement is trimmed before use', async () => {
|
|
1226
|
+
const { influxModule, writeNode } = createWriteNode();
|
|
1227
|
+
const msg = {
|
|
1228
|
+
measurement: ' spaced ',
|
|
1229
|
+
payload: { fields: { value: 1 } }
|
|
1230
|
+
};
|
|
1231
|
+
const send = jest.fn();
|
|
1232
|
+
const done = jest.fn();
|
|
1233
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1234
|
+
|
|
1235
|
+
const point = influxModule.__getLastPoint();
|
|
1236
|
+
expect(point.measurement).toBe('spaced');
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
test('whitespace-only measurement with no node default errors', async () => {
|
|
1240
|
+
const { RED } = setup();
|
|
1241
|
+
const ConfigCtor = RED._types['influxdb3-config'];
|
|
1242
|
+
const WriteCtor = RED._types['influxdb3-write'];
|
|
1243
|
+
|
|
1244
|
+
const configNode = new ConfigCtor({
|
|
1245
|
+
host: 'https://example.com',
|
|
1246
|
+
database: 'metrics',
|
|
1247
|
+
name: 'Test',
|
|
1248
|
+
credentials: { token: 'token' }
|
|
1249
|
+
});
|
|
1250
|
+
const writeNode = new WriteCtor({ influxdb: configNode, measurement: '', database: '' });
|
|
1251
|
+
|
|
1252
|
+
const msg = { measurement: ' ', payload: { fields: { value: 1 } } };
|
|
1253
|
+
const send = jest.fn();
|
|
1254
|
+
const done = jest.fn();
|
|
1255
|
+
await writeNode._handlers.input(msg, send, done);
|
|
1256
|
+
|
|
1257
|
+
expect(done).toHaveBeenCalledWith(expect.any(Error));
|
|
1258
|
+
expect(done.mock.calls[0][0].message).toContain('Measurement not specified');
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
|
|
940
1262
|
describe('InfluxDB v3 config node – credentials warning', () => {
|
|
941
1263
|
test('logs warning when credentials object is undefined', () => {
|
|
942
1264
|
const RED = buildRED();
|
package/influxdb3.html
CHANGED
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
<dt>Database <span class="property-type">string</span></dt>
|
|
63
63
|
<dd>The default database (bucket) name to write to</dd>
|
|
64
64
|
<dt>Verify TLS <span class="property-type">boolean</span></dt>
|
|
65
|
-
<dd>When unchecked,
|
|
65
|
+
<dd>When unchecked, disables TLS certificate verification for this connection only (insecure; use only for trusted local instances)</dd>
|
|
66
66
|
<dt>CA Cert Path <span class="property-type">string</span></dt>
|
|
67
|
-
<dd>Optional filesystem path used to
|
|
67
|
+
<dd>Optional filesystem path to a PEM CA certificate, used to verify this connection's TLS certificate</dd>
|
|
68
68
|
</dl>
|
|
69
69
|
</script>
|
|
70
70
|
|
package/influxdb3.js
CHANGED
|
@@ -13,6 +13,8 @@ module.exports = function(RED) {
|
|
|
13
13
|
// Point.setTimestamp(date)
|
|
14
14
|
// Point.toLineProtocol()
|
|
15
15
|
const { InfluxDBClient, Point } = require('@influxdata/influxdb3-client');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const { validateLineProtocol } = require('./lib/line-protocol');
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Normalize host URL to ensure it has trailing slash
|
|
@@ -75,23 +77,44 @@ module.exports = function(RED) {
|
|
|
75
77
|
|
|
76
78
|
const normalizedHost = normalizeHost(configNode.host);
|
|
77
79
|
|
|
80
|
+
// Build per-client transport (TLS) options. These are passed only to this
|
|
81
|
+
// client's HTTPS requests, so they do NOT affect other connections or the
|
|
82
|
+
// rest of the Node-RED process (unlike NODE_TLS_REJECT_UNAUTHORIZED /
|
|
83
|
+
// NODE_EXTRA_CA_CERTS, which are global and read only at process startup).
|
|
84
|
+
const transportOptions = {};
|
|
85
|
+
|
|
78
86
|
if (!configNode.tlsRejectUnauthorized) {
|
|
79
|
-
|
|
80
|
-
RED.log.warn(
|
|
87
|
+
transportOptions.rejectUnauthorized = false;
|
|
88
|
+
RED.log.warn(
|
|
89
|
+
'InfluxDB v3: TLS certificate verification is disabled for this connection. ' +
|
|
90
|
+
'This is insecure and should only be used for trusted local instances.'
|
|
91
|
+
);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
if (configNode.caCertPath) {
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
try {
|
|
96
|
+
transportOptions.ca = fs.readFileSync(configNode.caCertPath);
|
|
97
|
+
RED.log.info(`InfluxDB v3: Using custom CA certificate from ${configNode.caCertPath}`);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Failed to read CA certificate from '${configNode.caCertPath}': ${error.message}`,
|
|
101
|
+
{ cause: error }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
RED.log.info(`InfluxDB v3: Connecting to ${normalizedHost} with database ${configNode.database}`);
|
|
89
107
|
|
|
90
|
-
|
|
108
|
+
const clientOptions = {
|
|
91
109
|
host: normalizedHost,
|
|
92
110
|
token: configNode.token,
|
|
93
111
|
database: configNode.database
|
|
94
|
-
}
|
|
112
|
+
};
|
|
113
|
+
if (Object.keys(transportOptions).length > 0) {
|
|
114
|
+
clientOptions.transportOptions = transportOptions;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
configNode.client = new InfluxDBClient(clientOptions);
|
|
95
118
|
|
|
96
119
|
RED.log.info('InfluxDB v3: Client created successfully');
|
|
97
120
|
}
|
|
@@ -157,38 +180,6 @@ module.exports = function(RED) {
|
|
|
157
180
|
}
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
/**
|
|
161
|
-
* Validate that a string looks like InfluxDB line protocol.
|
|
162
|
-
* Returns null if valid, or an error message string if invalid.
|
|
163
|
-
* @param {string} lp - Trimmed line protocol string
|
|
164
|
-
* @returns {string|null}
|
|
165
|
-
*/
|
|
166
|
-
function validateLineProtocol(lp) {
|
|
167
|
-
// Detect JSON-like strings (both valid JSON and JS object notation)
|
|
168
|
-
if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
|
|
169
|
-
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
170
|
-
return (
|
|
171
|
-
'The payload appears to be a JSON/object string, not line protocol. ' +
|
|
172
|
-
'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
|
|
173
|
-
'Use a JSON parse node before this node to convert the string to an object. ' +
|
|
174
|
-
`Received string: ${preview}`
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Line protocol must have at least: measurement field=value
|
|
179
|
-
// i.e. at least one space and one '=' in the field set
|
|
180
|
-
if (!lp.includes(' ') || !lp.includes('=')) {
|
|
181
|
-
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
182
|
-
return (
|
|
183
|
-
'The payload string does not appear to be valid line protocol. ' +
|
|
184
|
-
'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
|
|
185
|
-
`Received: ${preview}`
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
183
|
/**
|
|
193
184
|
* Process a field value and add it to a Point.
|
|
194
185
|
* Returns true if the field was added, false if it was skipped.
|
|
@@ -253,10 +244,10 @@ module.exports = function(RED) {
|
|
|
253
244
|
if (!Number.isInteger(value)) {
|
|
254
245
|
node.warn(
|
|
255
246
|
`Field '${key}' is marked as integer but value is ${value}${context}. ` +
|
|
256
|
-
`Value will be truncated to ${Math.
|
|
247
|
+
`Value will be truncated to ${Math.trunc(value)}.`
|
|
257
248
|
);
|
|
258
249
|
}
|
|
259
|
-
point.setIntegerField(key, Math.
|
|
250
|
+
point.setIntegerField(key, Math.trunc(value));
|
|
260
251
|
} else {
|
|
261
252
|
point.setFloatField(key, value);
|
|
262
253
|
}
|
|
@@ -297,7 +288,11 @@ module.exports = function(RED) {
|
|
|
297
288
|
* @returns {{lineProtocol: string}|{error: string}} result or error
|
|
298
289
|
*/
|
|
299
290
|
function buildLineProtocol(msg) {
|
|
300
|
-
|
|
291
|
+
// Trim each source before the fallback so a blank/whitespace-only
|
|
292
|
+
// msg.measurement falls back to the node default (instead of being used
|
|
293
|
+
// verbatim) and never produces a measurement made of spaces.
|
|
294
|
+
const trim = (m) => (typeof m === 'string' ? m.trim() : m);
|
|
295
|
+
const measurement = trim(msg.measurement) || trim(node.measurement);
|
|
301
296
|
|
|
302
297
|
if (!measurement) {
|
|
303
298
|
return { error: 'Measurement not specified' };
|
|
@@ -307,10 +302,25 @@ module.exports = function(RED) {
|
|
|
307
302
|
|
|
308
303
|
// Add tags
|
|
309
304
|
if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
|
|
305
|
+
const tagContext = measurement ? ` (measurement: '${measurement}')` : '';
|
|
310
306
|
for (const [key, value] of Object.entries(msg.payload.tags)) {
|
|
311
|
-
if (value
|
|
312
|
-
|
|
307
|
+
if (value === null || value === undefined) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// Guard against objects/arrays, which would otherwise be coerced to
|
|
311
|
+
// useless strings like "[object Object]". Mirrors addFieldToPoint.
|
|
312
|
+
if (typeof value === 'object') {
|
|
313
|
+
const typeName = Array.isArray(value)
|
|
314
|
+
? 'Array'
|
|
315
|
+
: (value.constructor ? value.constructor.name : 'object');
|
|
316
|
+
node.warn(
|
|
317
|
+
`Skipping tag '${key}': unsupported type 'object' (${typeName})${tagContext}. ` +
|
|
318
|
+
`Actual value: ${safeStringify(value)}. ` +
|
|
319
|
+
`Tag values must be a string, number, or boolean.`
|
|
320
|
+
);
|
|
321
|
+
continue;
|
|
313
322
|
}
|
|
323
|
+
point.setTag(key, String(value));
|
|
314
324
|
}
|
|
315
325
|
}
|
|
316
326
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure line-protocol helpers, extracted so they can be unit-tested directly
|
|
3
|
+
* (and shared) rather than re-implemented in test files.
|
|
4
|
+
* @module lib/line-protocol
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate that a string looks like InfluxDB line protocol.
|
|
11
|
+
* Returns null if valid, or an error message string if invalid.
|
|
12
|
+
* @param {string} lp - Trimmed line protocol string
|
|
13
|
+
* @returns {string|null}
|
|
14
|
+
*/
|
|
15
|
+
function validateLineProtocol(lp) {
|
|
16
|
+
// Detect JSON-like strings (both valid JSON and JS object notation)
|
|
17
|
+
if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
|
|
18
|
+
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
19
|
+
return (
|
|
20
|
+
'The payload appears to be a JSON/object string, not line protocol. ' +
|
|
21
|
+
'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
|
|
22
|
+
'Use a JSON parse node before this node to convert the string to an object. ' +
|
|
23
|
+
`Received string: ${preview}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Line protocol must have at least: measurement field=value
|
|
28
|
+
// i.e. at least one space and one '=' in the field set
|
|
29
|
+
if (!lp.includes(' ') || !lp.includes('=')) {
|
|
30
|
+
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
31
|
+
return (
|
|
32
|
+
'The payload string does not appear to be valid line protocol. ' +
|
|
33
|
+
'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
|
|
34
|
+
`Received: ${preview}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { validateLineProtocol };
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-influxdb3",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Node-RED nodes for InfluxDB v3 integration",
|
|
5
5
|
"main": "influxdb3.js",
|
|
6
6
|
"scripts": {
|
|
7
|
+
"lint": "eslint .",
|
|
7
8
|
"test": "jest"
|
|
8
9
|
},
|
|
9
10
|
"keywords": [
|
|
@@ -33,9 +34,12 @@
|
|
|
33
34
|
}
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@influxdata/influxdb3-client": "^2.
|
|
37
|
+
"@influxdata/influxdb3-client": "^2.2.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"eslint": "^10.4.1",
|
|
42
|
+
"globals": "^17.6.0",
|
|
39
43
|
"jest": "^30.3.0"
|
|
40
44
|
}
|
|
41
45
|
}
|
package/renovate.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
3
3
|
"extends": [
|
|
4
|
-
"config:
|
|
4
|
+
"config:base"
|
|
5
|
+
],
|
|
6
|
+
"platformAutomerge": true,
|
|
7
|
+
"packageRules": [
|
|
8
|
+
{
|
|
9
|
+
"description": "Automerge non-major updates",
|
|
10
|
+
"matchUpdateTypes": ["minor", "patch"],
|
|
11
|
+
"automerge": true
|
|
12
|
+
}
|
|
5
13
|
]
|
|
6
14
|
}
|
|
@@ -1,32 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for line protocol string validation.
|
|
3
|
-
*
|
|
3
|
+
* Imports the real validateLineProtocol from the shipping code so the test
|
|
4
|
+
* cannot drift from the implementation.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
// (the function is not exported from influxdb3.js)
|
|
8
|
-
function validateLineProtocol(lp) {
|
|
9
|
-
if (/^\{[\s\S]*}$/.test(lp) || /^\[[\s\S]*]$/.test(lp)) {
|
|
10
|
-
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
11
|
-
return (
|
|
12
|
-
'The payload appears to be a JSON/object string, not line protocol. ' +
|
|
13
|
-
'If you are sending JSON, ensure msg.payload is a parsed object (not a string). ' +
|
|
14
|
-
'Use a JSON parse node before this node to convert the string to an object. ' +
|
|
15
|
-
`Received string: ${preview}`
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (!lp.includes(' ') || !lp.includes('=')) {
|
|
20
|
-
const preview = lp.length > 100 ? lp.substring(0, 100) + '...' : lp;
|
|
21
|
-
return (
|
|
22
|
-
'The payload string does not appear to be valid line protocol. ' +
|
|
23
|
-
'Expected format: measurement[,tag=val] field=val[,field=val] [timestamp]. ' +
|
|
24
|
-
`Received: ${preview}`
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
7
|
+
const { validateLineProtocol } = require('../lib/line-protocol');
|
|
30
8
|
|
|
31
9
|
describe('Line protocol string validation', () => {
|
|
32
10
|
test('valid line protocol returns null', () => {
|
package/test/point-api.test.js
CHANGED
|
@@ -1,56 +1,69 @@
|
|
|
1
1
|
const { Point } = require('@influxdata/influxdb3-client');
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Pins the real @influxdata/influxdb3-client Point API that influxdb3.js depends on.
|
|
5
|
+
* The node uses the type-specific setters (setFloatField / setIntegerField /
|
|
6
|
+
* setStringField / setBooleanField / setTag / setTimestamp) and toLineProtocol(),
|
|
7
|
+
* so those are what we verify here — if the library changes them, this suite fails.
|
|
8
|
+
*/
|
|
9
|
+
describe('@influxdata/influxdb3-client v2.x Point API used by influxdb3.js', () => {
|
|
10
|
+
test('setFloatField writes a float field', () => {
|
|
11
|
+
const lp = new Point('test').setFloatField('temp', 23.5).toLineProtocol();
|
|
12
|
+
expect(lp).toContain('temp=23.5');
|
|
7
13
|
});
|
|
8
14
|
|
|
9
|
-
test('
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
expect(lp).toContain('temp=23.5');
|
|
15
|
+
test('setFloatField keeps whole numbers as floats (no i suffix)', () => {
|
|
16
|
+
const lp = new Point('test').setFloatField('count', 60).toLineProtocol();
|
|
17
|
+
expect(lp).toContain('count=60');
|
|
18
|
+
expect(lp).not.toContain('count=60i');
|
|
14
19
|
});
|
|
15
20
|
|
|
16
|
-
test('
|
|
17
|
-
const
|
|
18
|
-
point.setField('count', 42, 'integer');
|
|
19
|
-
const lp = point.toLineProtocol();
|
|
21
|
+
test('setIntegerField writes an integer field with the i suffix', () => {
|
|
22
|
+
const lp = new Point('test').setIntegerField('count', 42).toLineProtocol();
|
|
20
23
|
expect(lp).toContain('count=42i');
|
|
21
24
|
});
|
|
22
25
|
|
|
23
|
-
test('
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
test('setIntegerField supports negative integers', () => {
|
|
27
|
+
const lp = new Point('test').setIntegerField('offset', -7).toLineProtocol();
|
|
28
|
+
expect(lp).toContain('offset=-7i');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('setStringField writes a quoted string field', () => {
|
|
32
|
+
const lp = new Point('test').setStringField('status', 'ok').toLineProtocol();
|
|
27
33
|
expect(lp).toContain('status="ok"');
|
|
28
34
|
});
|
|
29
35
|
|
|
30
|
-
test('
|
|
31
|
-
const
|
|
32
|
-
point.setField('active', true);
|
|
33
|
-
const lp = point.toLineProtocol();
|
|
36
|
+
test('setBooleanField writes a boolean field', () => {
|
|
37
|
+
const lp = new Point('test').setBooleanField('active', true).toLineProtocol();
|
|
34
38
|
// Library serializes booleans as T/F in line protocol
|
|
35
39
|
expect(lp).toContain('active=T');
|
|
36
40
|
});
|
|
37
41
|
|
|
38
|
-
test('
|
|
39
|
-
const
|
|
40
|
-
|
|
42
|
+
test('setTag writes a tag in the measurement,tag=value section', () => {
|
|
43
|
+
const lp = new Point('test')
|
|
44
|
+
.setTag('location', 'room1')
|
|
45
|
+
.setFloatField('value', 1)
|
|
46
|
+
.toLineProtocol();
|
|
47
|
+
expect(lp).toContain('test,location=room1 ');
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
test('
|
|
44
|
-
const
|
|
45
|
-
|
|
50
|
+
test('setTimestamp accepts a Date and appends a nanosecond timestamp', () => {
|
|
51
|
+
const lp = new Point('test')
|
|
52
|
+
.setFloatField('value', 1)
|
|
53
|
+
.setTimestamp(new Date(1700000000000))
|
|
54
|
+
.toLineProtocol();
|
|
55
|
+
// 1700000000000 ms -> 1700000000000000000 ns
|
|
56
|
+
expect(lp).toContain('1700000000000000000');
|
|
46
57
|
});
|
|
47
58
|
|
|
48
|
-
test('
|
|
59
|
+
test('the setters used by the node are all functions', () => {
|
|
49
60
|
const point = new Point('test');
|
|
50
|
-
expect(typeof point.setIntegerField).toBe('function');
|
|
51
61
|
expect(typeof point.setFloatField).toBe('function');
|
|
62
|
+
expect(typeof point.setIntegerField).toBe('function');
|
|
52
63
|
expect(typeof point.setStringField).toBe('function');
|
|
53
64
|
expect(typeof point.setBooleanField).toBe('function');
|
|
65
|
+
expect(typeof point.setTag).toBe('function');
|
|
66
|
+
expect(typeof point.setTimestamp).toBe('function');
|
|
67
|
+
expect(typeof point.toLineProtocol).toBe('function');
|
|
54
68
|
});
|
|
55
69
|
});
|
|
56
|
-
|