@yarkivaev/scada 1.5.0 → 2.3.45
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 +196 -33
- package/db/migrations/C0001__central_drop_metrics.sql +1 -0
- package/db/migrations/C0002__central_revoke_delete.sql +12 -0
- package/db/migrations/C0003__central_operators.sql +7 -0
- package/db/migrations/C0004__central_operations_grants.sql +2 -0
- package/db/migrations/C0005__central_operators_grants.sql +1 -0
- package/db/migrations/C0006__central_operators_registration.sql +16 -0
- package/db/migrations/E0001__edge_metrics.sql +8 -0
- package/db/migrations/E0002__edge_retention_delete.sql +8 -0
- package/db/migrations/E0003__edge_operations_grants.sql +2 -0
- package/db/migrations/V0001__baseline.sql +25 -0
- package/db/migrations/V0002__sink_extension_tables.sql +6 -0
- package/db/migrations/V0003__operations.sql +10 -0
- package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
- package/db/migrations/V0005__user_decisions_operator.sql +2 -0
- package/index.js +75 -46
- package/package.json +28 -9
- package/src/application/bindSilentStreams.js +20 -0
- package/src/application/edgeOperatorCatalog.js +45 -0
- package/src/application/export/exportJob.js +118 -0
- package/src/application/export/exportQuery.js +32 -0
- package/src/application/export/exportSink.js +25 -0
- package/src/application/export/exportStream.js +33 -0
- package/src/application/machineInPlant.js +20 -0
- package/src/application/metricsPlant.js +35 -0
- package/src/application/plantApi.js +49 -0
- package/src/application/plantOperations.js +19 -0
- package/src/application/plantServer.js +107 -0
- package/src/application/shopWithTimeline.js +112 -0
- package/src/application/siteOperatorCatalog.js +78 -0
- package/src/application/siteServer.js +191 -0
- package/src/application/supervisorSink.js +97 -0
- package/src/application/timelineOperatorFromEnv.js +19 -0
- package/src/bin/supervisor-sink.js +28 -0
- package/src/{alert.js → domain/alerting/alert.js} +2 -2
- package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
- package/src/domain/alerting/ingest.js +18 -0
- package/src/domain/ingest/ingestCursor.js +26 -0
- package/src/domain/operation/operation.js +23 -0
- package/src/domain/operation/operations.js +81 -0
- package/src/domain/operator/operator.js +23 -0
- package/src/domain/plant/machine.js +32 -0
- package/src/domain/plant/plant.js +20 -0
- package/src/domain/plant/shop.js +24 -0
- package/src/domain/segment/dispatch.js +31 -0
- package/src/domain/segment/normalize.js +31 -0
- package/src/domain/segment/silenceBudget.js +16 -0
- package/src/{initialized.js → domain/shared/initialized.js} +3 -4
- package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
- package/src/domain/timeline/timeline.js +25 -0
- package/src/infrastructure/catalog/tag-catalog.json +27 -0
- package/src/infrastructure/catalog/tagCatalog.js +65 -0
- package/src/infrastructure/client/index.js +20 -0
- package/src/infrastructure/client/machineClient.js +159 -0
- package/src/infrastructure/client/machineOperationsClient.js +159 -0
- package/src/infrastructure/client/scadaClient.js +67 -0
- package/src/infrastructure/client/sseConnection.js +42 -0
- package/src/infrastructure/http/edge/edgeApi.js +54 -0
- package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
- package/src/infrastructure/http/edge/metricsSensor.js +15 -0
- package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
- package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
- package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
- package/src/infrastructure/http/edge/stateAccess.js +13 -0
- package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
- package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
- package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
- package/src/infrastructure/http/plant/json/operationJson.js +28 -0
- package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
- package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
- package/src/infrastructure/http/plant/operationAudit.js +56 -0
- package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
- package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
- package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
- package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
- package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
- package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
- package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
- package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
- package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
- package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
- package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
- package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
- package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
- package/src/infrastructure/http/plant/timelineOperator.js +87 -0
- package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
- package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
- package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
- package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
- package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
- package/src/infrastructure/ingest/cooldown.js +24 -0
- package/src/infrastructure/ingest/db/migrate.js +78 -0
- package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
- package/src/infrastructure/ingest/db/runRetention.js +58 -0
- package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
- package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
- package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
- package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
- package/src/infrastructure/ingest/processingErrorLog.js +25 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
- package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
- package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
- package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
- package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
- package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
- package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
- package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
- package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
- package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
- package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
- package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
- package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
- package/src/infrastructure/messaging/stomp/timeline.js +21 -0
- package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
- package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
- package/src/infrastructure/operators/centralOperators.js +64 -0
- package/src/infrastructure/operators/edgeOperators.js +39 -0
- package/src/infrastructure/operators/operatorById.js +33 -0
- package/src/infrastructure/operators/operatorExtras.js +41 -0
- package/src/infrastructure/operators/operators.js +32 -0
- package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
- package/src/infrastructure/operators/operatorsSync.js +57 -0
- package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
- package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
- package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
- package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
- package/src/infrastructure/persistence/memory/alerts.js +21 -0
- package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
- package/src/infrastructure/persistence/memory/metrics.js +69 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
- package/src/infrastructure/persistence/memory/operations.js +87 -0
- package/src/infrastructure/persistence/memory/segments.js +83 -0
- package/src/infrastructure/persistence/memory/timeline.js +56 -0
- package/src/infrastructure/persistence/metricsSensor.js +112 -0
- package/src/infrastructure/persistence/pg/alerts.js +25 -0
- package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
- package/src/infrastructure/persistence/pg/metrics.js +73 -0
- package/src/infrastructure/persistence/pg/operations.js +95 -0
- package/src/infrastructure/persistence/pg/operators.js +118 -0
- package/src/infrastructure/persistence/pg/segments.js +48 -0
- package/src/infrastructure/persistence/pg/timeline.js +53 -0
- package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
- package/src/infrastructure/persistence/postgresPool.js +24 -0
- package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
- package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
- package/src/infrastructure/sync/operationCodec.js +75 -0
- package/src/infrastructure/sync/operationSyncIngest.js +82 -0
- package/src/infrastructure/sync/operationSyncSink.js +40 -0
- package/src/activeMelting.js +0 -54
- package/src/completedMelting.js +0 -42
- package/src/event.js +0 -24
- package/src/events.js +0 -51
- package/src/interval.js +0 -18
- package/src/machineChronology.js +0 -79
- package/src/meltingChronology.js +0 -37
- package/src/meltingMachine.js +0 -53
- package/src/meltingRuleEngine.js +0 -37
- package/src/meltingShop.js +0 -32
- package/src/meltings.js +0 -97
- package/src/monitoredMeltingMachine.js +0 -33
- package/src/plant.js +0 -23
- package/src/postgresSensor.js +0 -105
- package/src/requests.js +0 -44
- package/src/rule.js +0 -33
- package/src/rules.js +0 -23
- package/src/scyllaSensor.js +0 -54
- package/src/segments.js +0 -64
- package/src/sqliteSensor.js +0 -106
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
batch,
|
|
3
|
+
circuit,
|
|
4
|
+
clock,
|
|
5
|
+
modbusRtuBusSource,
|
|
6
|
+
modbusRtuSource,
|
|
7
|
+
modbusSource,
|
|
8
|
+
mqttSink
|
|
9
|
+
} from '@yarkivaev/source-to-sink';
|
|
10
|
+
import { parseModbusDeviceSpec } from './modbusDeviceSpec.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds a TCP Modbus polling source for one device.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} device - Parsed TCP device descriptor
|
|
16
|
+
* @param {object} config - Pipeline config
|
|
17
|
+
* @param {object} collector - Batch collector
|
|
18
|
+
* @param {object} clk - Clock instance
|
|
19
|
+
* @returns {object} Modbus polling source
|
|
20
|
+
*/
|
|
21
|
+
function buildTcpSource(device, config, collector, clk) {
|
|
22
|
+
const transformer = config.transformerFactory(device.name, collector);
|
|
23
|
+
return modbusSource(
|
|
24
|
+
device.host,
|
|
25
|
+
device.port,
|
|
26
|
+
config.address,
|
|
27
|
+
config.count,
|
|
28
|
+
config.interval,
|
|
29
|
+
transformer,
|
|
30
|
+
clk
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Serial bus key for grouping RTU devices that share one port.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} device - Parsed RTU device descriptor
|
|
38
|
+
* @returns {string} Group key
|
|
39
|
+
*/
|
|
40
|
+
function rtuBusKey(device) {
|
|
41
|
+
const { baudRate, dataBits, stopBits, parity } = device.serial;
|
|
42
|
+
return `${device.path}|${baudRate}|${dataBits}|${stopBits}|${parity}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Groups RTU devices by shared serial port and line settings.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<object>} rtuDevices - Parsed RTU device descriptors
|
|
49
|
+
* @returns {Array<Array<object>>} Device groups
|
|
50
|
+
*/
|
|
51
|
+
function groupRtuDevices(rtuDevices) {
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
for (const device of rtuDevices) {
|
|
54
|
+
const key = rtuBusKey(device);
|
|
55
|
+
const list = groups.get(key) || [];
|
|
56
|
+
list.push(device);
|
|
57
|
+
groups.set(key, list);
|
|
58
|
+
}
|
|
59
|
+
return [...groups.values()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds a single-slave RTU polling source.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} device - Parsed RTU device
|
|
66
|
+
* @param {object} config - Pipeline config
|
|
67
|
+
* @param {object} collector - Batch collector
|
|
68
|
+
* @param {object} clk - Clock instance
|
|
69
|
+
* @returns {object} Modbus RTU source
|
|
70
|
+
*/
|
|
71
|
+
function buildSingleRtuSource(device, config, collector, clk) {
|
|
72
|
+
return modbusRtuSource(
|
|
73
|
+
device.path,
|
|
74
|
+
device.serial,
|
|
75
|
+
config.address,
|
|
76
|
+
config.count,
|
|
77
|
+
config.interval,
|
|
78
|
+
config.transformerFactory(device.name, collector),
|
|
79
|
+
clk
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Builds a multi-slave RTU bus source for one serial port.
|
|
85
|
+
*
|
|
86
|
+
* @param {Array<object>} devices - RTU devices on the same port
|
|
87
|
+
* @param {object} config - Pipeline config
|
|
88
|
+
* @param {object} collector - Batch collector
|
|
89
|
+
* @returns {object} Modbus RTU bus source
|
|
90
|
+
*/
|
|
91
|
+
function buildBusRtuSource(devices, config, collector) {
|
|
92
|
+
const slaves = devices.map((device) => {
|
|
93
|
+
return {
|
|
94
|
+
slaveId: device.serial.slaveId,
|
|
95
|
+
collector: config.transformerFactory(device.name, collector)
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
const first = devices[0];
|
|
99
|
+
return modbusRtuBusSource(first.path, first.serial, slaves, {
|
|
100
|
+
address: config.address,
|
|
101
|
+
count: config.count,
|
|
102
|
+
interval: config.interval
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds RTU sources, merging slaves on the same serial port into one bus.
|
|
108
|
+
*
|
|
109
|
+
* @param {Array<object>} rtuDevices - Parsed RTU device descriptors
|
|
110
|
+
* @param {object} config - Pipeline config
|
|
111
|
+
* @param {object} collector - Batch collector
|
|
112
|
+
* @param {object} clk - Clock instance
|
|
113
|
+
* @returns {Array<object>} Modbus polling sources
|
|
114
|
+
*/
|
|
115
|
+
function buildRtuSources(rtuDevices, config, collector, clk) {
|
|
116
|
+
return groupRtuDevices(rtuDevices).map((devices) => {
|
|
117
|
+
if (devices.length === 1) {
|
|
118
|
+
return buildSingleRtuSource(devices[0], config, collector, clk);
|
|
119
|
+
}
|
|
120
|
+
return buildBusRtuSource(devices, config, collector);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validates modbusMqtt constructor arguments.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} mqtt - MQTT broker URL
|
|
128
|
+
* @param {string} devices - Device specs string
|
|
129
|
+
* @param {object} config - Pipeline config
|
|
130
|
+
*/
|
|
131
|
+
function assertModbusMqttArgs(mqtt, devices, config) {
|
|
132
|
+
if (typeof mqtt !== 'string' || mqtt.length === 0) {
|
|
133
|
+
throw new Error('MQTT URL must be a non-empty string');
|
|
134
|
+
}
|
|
135
|
+
if (typeof devices !== 'string' || devices.length === 0) {
|
|
136
|
+
throw new Error('Devices must be a non-empty string');
|
|
137
|
+
}
|
|
138
|
+
if (!config || typeof config !== 'object') {
|
|
139
|
+
throw new Error('Config must be an object');
|
|
140
|
+
}
|
|
141
|
+
if (typeof config.transformerFactory !== 'function') {
|
|
142
|
+
throw new Error('transformerFactory is required for modbusMqtt');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parses device specs and builds TCP plus RTU polling sources.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} devices - Comma-separated device specs
|
|
150
|
+
* @param {object} config - Pipeline config
|
|
151
|
+
* @param {object} collector - Batch collector
|
|
152
|
+
* @param {object} clk - Clock instance
|
|
153
|
+
* @returns {Array<object>} Modbus polling sources
|
|
154
|
+
*/
|
|
155
|
+
function buildSources(devices, config, collector, clk) {
|
|
156
|
+
const parsed = devices.split(',').map((spec) => {
|
|
157
|
+
return parseModbusDeviceSpec(spec.trim());
|
|
158
|
+
});
|
|
159
|
+
const tcp = parsed.filter((device) => {
|
|
160
|
+
return device.kind === 'tcp';
|
|
161
|
+
});
|
|
162
|
+
const rtu = parsed.filter((device) => {
|
|
163
|
+
return device.kind === 'rtu';
|
|
164
|
+
});
|
|
165
|
+
return [
|
|
166
|
+
...tcp.map((device) => {
|
|
167
|
+
return buildTcpSource(device, config, collector, clk);
|
|
168
|
+
}),
|
|
169
|
+
...buildRtuSources(rtu, config, collector, clk)
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pipeline for polling Modbus devices and publishing to MQTT.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} mqtt - MQTT broker URL
|
|
177
|
+
* @param {string} devices - Comma-separated specs: name:host:port or name:rtu:path:baud[:line][:slaveId]
|
|
178
|
+
* @param {object} config - interval, address, count, threshold, timeout, clientId, transformerFactory
|
|
179
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
180
|
+
*/
|
|
181
|
+
export default function modbusMqtt(mqtt, devices, config) {
|
|
182
|
+
assertModbusMqttArgs(mqtt, devices, config);
|
|
183
|
+
const clk = clock();
|
|
184
|
+
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
185
|
+
const sink = mqttSink(mqtt, {
|
|
186
|
+
clientId: config.clientId || 'scada-modbus',
|
|
187
|
+
qos: 1
|
|
188
|
+
});
|
|
189
|
+
const collector = batch(sink, 5, breaker);
|
|
190
|
+
const sources = buildSources(devices, config, collector, clk);
|
|
191
|
+
return {
|
|
192
|
+
start() {
|
|
193
|
+
sink.start();
|
|
194
|
+
for (const source of sources) {
|
|
195
|
+
source.start();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
stop() {
|
|
199
|
+
for (const source of sources) {
|
|
200
|
+
source.stop();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
batch,
|
|
3
|
+
circuit,
|
|
4
|
+
clock,
|
|
5
|
+
mqttSource,
|
|
6
|
+
timedBatch
|
|
7
|
+
} from '@yarkivaev/source-to-sink';
|
|
8
|
+
import metricsCodec from './metricsTransformer.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pipeline for streaming MQTT sensor data to a storage sink.
|
|
12
|
+
*
|
|
13
|
+
* Subscribes to MQTT topic and batches messages before writing
|
|
14
|
+
* to the provided sink. Uses circuit breaker for failure isolation
|
|
15
|
+
* and time-based flushing for low-volume periods.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { clickhouseSink } from '@yarkivaev/source-to-sink';
|
|
19
|
+
* const sink = clickhouseSink('http://localhost:8123', 'scada.metrics');
|
|
20
|
+
* const pipeline = mqttMetrics(
|
|
21
|
+
* 'mqtt://localhost:1883',
|
|
22
|
+
* sink,
|
|
23
|
+
* 'sensors/#',
|
|
24
|
+
* { size: 100, interval: 5, threshold: 5, timeout: 60 }
|
|
25
|
+
* );
|
|
26
|
+
* pipeline.start();
|
|
27
|
+
* // ... later
|
|
28
|
+
* pipeline.stop();
|
|
29
|
+
*
|
|
30
|
+
* @param {string} mqtt - MQTT broker URL
|
|
31
|
+
* @param {object} sink - Sink with write(records) method
|
|
32
|
+
* @param {string} topic - MQTT topic pattern to subscribe
|
|
33
|
+
* @param {object} config - Pipeline configuration
|
|
34
|
+
* @param {number} config.size - Batch size before flush
|
|
35
|
+
* @param {number} config.interval - Seconds before time-based flush
|
|
36
|
+
* @param {number} config.threshold - Circuit breaker failure threshold
|
|
37
|
+
* @param {number} config.timeout - Circuit breaker timeout in seconds
|
|
38
|
+
* @param {string} [config.clientId] - MQTT client ID for persistent sessions
|
|
39
|
+
* @param {number} [config.sessionExpiryInterval] - Session expiry in seconds (default 3600)
|
|
40
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
41
|
+
*/
|
|
42
|
+
export default function mqttMetrics(mqtt, sink, topic, config) {
|
|
43
|
+
if (typeof mqtt !== 'string' || mqtt.length === 0) {
|
|
44
|
+
throw new Error('MQTT URL must be a non-empty string');
|
|
45
|
+
}
|
|
46
|
+
if (!sink || typeof sink.write !== 'function') {
|
|
47
|
+
throw new Error('Sink must have a write(records) method');
|
|
48
|
+
}
|
|
49
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
50
|
+
throw new Error('Topic must be a non-empty string');
|
|
51
|
+
}
|
|
52
|
+
if (!config || typeof config !== 'object') {
|
|
53
|
+
throw new Error('Config must be an object');
|
|
54
|
+
}
|
|
55
|
+
const clk = clock();
|
|
56
|
+
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
57
|
+
const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
|
|
58
|
+
const transformer = metricsCodec(collector);
|
|
59
|
+
const source = mqttSource(mqtt, topic, transformer, {
|
|
60
|
+
clientId: config.clientId,
|
|
61
|
+
sessionExpiryInterval: config.sessionExpiryInterval
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
/**
|
|
65
|
+
* Starts the pipeline.
|
|
66
|
+
*/
|
|
67
|
+
start() {
|
|
68
|
+
source.start();
|
|
69
|
+
},
|
|
70
|
+
/**
|
|
71
|
+
* Stops the pipeline.
|
|
72
|
+
*/
|
|
73
|
+
stop() {
|
|
74
|
+
source.stop();
|
|
75
|
+
collector.stop();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses REQUEST_TIMEOUT_MS from environment into a positive millisecond value.
|
|
5
|
+
*
|
|
6
|
+
* @param {string|undefined} raw - env value
|
|
7
|
+
* @returns {number} timeout in milliseconds
|
|
8
|
+
*/
|
|
9
|
+
export function parseRequestTimeoutMs(raw) {
|
|
10
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
11
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
12
|
+
}
|
|
13
|
+
const ms = Number.parseInt(String(raw), 10);
|
|
14
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
15
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
return ms;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { DEFAULT_REQUEST_TIMEOUT_MS };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { stompSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import alertCodec from '../codecs/alertCodec.js';
|
|
3
|
+
import alertSink from '../sinks/alertSink.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pipeline for streaming STOMP alert data to PostgreSQL alerts table.
|
|
7
|
+
*
|
|
8
|
+
* Subscribes to STOMP exchange and forwards alert messages directly
|
|
9
|
+
* to the alert sink without batching. Alerts are rare and require
|
|
10
|
+
* per-record INSERT/UPDATE branching, making batching unnecessary.
|
|
11
|
+
*
|
|
12
|
+
* Plant packages pass human-readable messages via config.translations
|
|
13
|
+
* (rule name → message). Without translations, the rule name is stored.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const pipeline = alertPipeline(
|
|
17
|
+
* 'stomp://rabbitmq:61613',
|
|
18
|
+
* pool,
|
|
19
|
+
* { login: 'guest', passcode: 'guest', host: '/', translations: { no_data: 'No data' } }
|
|
20
|
+
* );
|
|
21
|
+
* pipeline.start();
|
|
22
|
+
*
|
|
23
|
+
* @param {string} stomp - STOMP broker URL
|
|
24
|
+
* @param {object} pool - PostgreSQL pool
|
|
25
|
+
* @param {object} config - Pipeline configuration
|
|
26
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
27
|
+
*/
|
|
28
|
+
export default function alertPipeline(stomp, pool, config) {
|
|
29
|
+
const sink = alertSink(pool);
|
|
30
|
+
const codec = alertCodec(sink, config.translations || {});
|
|
31
|
+
const source = stompSource(stomp, '/exchange/scada.alerts', codec,
|
|
32
|
+
{ login: config.login, passcode: config.passcode, host: config.host });
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Starts the pipeline.
|
|
36
|
+
*/
|
|
37
|
+
start() {
|
|
38
|
+
source.start();
|
|
39
|
+
},
|
|
40
|
+
/**
|
|
41
|
+
* Stops the pipeline.
|
|
42
|
+
*/
|
|
43
|
+
stop() {
|
|
44
|
+
source.stop();
|
|
45
|
+
sink.stop();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { stompSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import userDecisionCodec from '../codecs/userDecisionCodec.js';
|
|
3
|
+
import userDecisionSink from '../sinks/userDecisionSink.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pipeline for logging operator tag decisions from RabbitMQ to PostgreSQL.
|
|
7
|
+
*
|
|
8
|
+
* Subscribes to the user_decisions STOMP exchange and inserts each received
|
|
9
|
+
* decision into the user_decisions audit table. Decisions carry machine,
|
|
10
|
+
* start timestamp, operator identity, and full JSON payload.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const pipeline = decisionPipeline(
|
|
14
|
+
* 'stomp://rabbitmq:61613',
|
|
15
|
+
* 'postgresql://scada:scada@postgres/scada',
|
|
16
|
+
* { login: 'guest', passcode: 'guest', host: '/' }
|
|
17
|
+
* );
|
|
18
|
+
* pipeline.start();
|
|
19
|
+
*
|
|
20
|
+
* @param {string} stomp - STOMP broker URL
|
|
21
|
+
* @param {string} postgres - PostgreSQL connection URL
|
|
22
|
+
* @param {object} config - Pipeline configuration
|
|
23
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
24
|
+
*/
|
|
25
|
+
export default function decisionPipeline(stomp, pool, config) {
|
|
26
|
+
const sink = userDecisionSink(pool);
|
|
27
|
+
const codec = userDecisionCodec(sink);
|
|
28
|
+
const source = stompSource(stomp, '/exchange/scada.user_decisions', codec,
|
|
29
|
+
{ login: config.login, passcode: config.passcode, host: config.host });
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* Starts consuming user decisions from RabbitMQ.
|
|
33
|
+
*/
|
|
34
|
+
start() {
|
|
35
|
+
source.start();
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Stops consuming and releases resources.
|
|
39
|
+
*/
|
|
40
|
+
stop() {
|
|
41
|
+
source.stop();
|
|
42
|
+
pool.end();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
postgresSink,
|
|
3
|
+
stompSource
|
|
4
|
+
} from '@yarkivaev/source-to-sink';
|
|
5
|
+
import segmentDispatch from '../../../domain/segment/dispatch.js';
|
|
6
|
+
import silenceBudget from '../../../domain/segment/silenceBudget.js';
|
|
7
|
+
import segmentCodec from '../codecs/segmentCodec.js';
|
|
8
|
+
import silentOpenWatch from '../silentOpenWatch.js';
|
|
9
|
+
import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
|
|
10
|
+
import closeSilentOpen from '../sinks/closeSilentOpen.js';
|
|
11
|
+
import retagSink from '../sinks/retagSink.js';
|
|
12
|
+
|
|
13
|
+
export const segmentColumns = ['machine', 'name', 'start_time', 'end_time', 'duration',
|
|
14
|
+
'options', 'tags', 'properties', 'resolved'];
|
|
15
|
+
export const segmentConflict = ['machine', 'start_time'];
|
|
16
|
+
export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved'];
|
|
17
|
+
export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
|
|
18
|
+
export const segmentsIngestDestination = '/queue/scada.segments.ingest';
|
|
19
|
+
|
|
20
|
+
export { default as segmentDispatch } from '../../../domain/segment/dispatch.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pipeline for streaming STOMP segment data to PostgreSQL segments table.
|
|
24
|
+
* Subscribes to the durable ingest queue so shovel traffic survives consumer gaps.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} stomp - STOMP broker URL
|
|
27
|
+
* @param {string} postgres - PostgreSQL connection URL
|
|
28
|
+
* @param {object} pool - pg pool
|
|
29
|
+
* @param {object} config - Pipeline configuration
|
|
30
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
31
|
+
*/
|
|
32
|
+
export default function segmentPipeline(stomp, postgres, pool, config) {
|
|
33
|
+
const segmentSink = postgresSink(postgres, 'segments', segmentColumns,
|
|
34
|
+
{ conflict: segmentConflict, update: segmentUpdateColumns });
|
|
35
|
+
const splitSink = postgresSink(postgres, 'segments', segmentColumns,
|
|
36
|
+
{ conflict: segmentConflict, update: splitUpdateColumns });
|
|
37
|
+
const retag = retagSink(pool);
|
|
38
|
+
const closer = closeOrphanOpen(pool);
|
|
39
|
+
const dispatch = segmentDispatch(segmentSink, retag, splitSink, closer);
|
|
40
|
+
const codec = segmentCodec(dispatch);
|
|
41
|
+
const destination = config.segmentsDestination || segmentsIngestDestination;
|
|
42
|
+
const source = stompSource(stomp, destination, codec,
|
|
43
|
+
{ login: config.login, passcode: config.passcode, host: config.host });
|
|
44
|
+
const window = config.segmentWindow || 15;
|
|
45
|
+
const budget = silenceBudget(window);
|
|
46
|
+
const pollMs = (config.poll || 5) * 1000;
|
|
47
|
+
const silence = silentOpenWatch(closeSilentOpen(pool), budget, { intervalMs: pollMs });
|
|
48
|
+
return {
|
|
49
|
+
destination,
|
|
50
|
+
start() {
|
|
51
|
+
source.start();
|
|
52
|
+
void silence.start();
|
|
53
|
+
},
|
|
54
|
+
stop() {
|
|
55
|
+
silence.stop();
|
|
56
|
+
source.stop();
|
|
57
|
+
pool.end();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emits a structured processing error line to stderr.
|
|
3
|
+
*
|
|
4
|
+
* Keeps payloads compact to avoid flooding logs with huge messages.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} stage - Processing stage identifier
|
|
7
|
+
* @param {Error} error - Original processing error
|
|
8
|
+
* @param {object} context - Optional diagnostic context
|
|
9
|
+
*/
|
|
10
|
+
export default function processingErrorLog(stage, error, context = {}) {
|
|
11
|
+
const safe = {};
|
|
12
|
+
for (const [key, value] of Object.entries(context)) {
|
|
13
|
+
if (typeof value === 'string' && value.length > 500) {
|
|
14
|
+
safe[key] = `${value.slice(0, 500)}...[truncated]`;
|
|
15
|
+
} else {
|
|
16
|
+
safe[key] = value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const payload = {
|
|
20
|
+
stage,
|
|
21
|
+
message: error?.message || String(error),
|
|
22
|
+
context: safe
|
|
23
|
+
};
|
|
24
|
+
process.stderr.write(`[supervisor-sink] processing error ${JSON.stringify(payload)}\n`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic wall-clock sweep that closes stale open segments without waiting for STOMP.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} closer - closeSilentOpen result with close(budgetSeconds)
|
|
5
|
+
* @param {number} budget - silence budget in seconds
|
|
6
|
+
* @param {object} options - intervalMs for sweep period
|
|
7
|
+
* @returns {object} watch with start() and stop()
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const watch = silentOpenWatch(closer, 30, { intervalMs: 5000 });
|
|
11
|
+
* await watch.start();
|
|
12
|
+
*/
|
|
13
|
+
export default function silentOpenWatch(closer, budget, options) {
|
|
14
|
+
if (!closer || typeof closer.close !== 'function') {
|
|
15
|
+
throw new Error('Closer must have a close() method');
|
|
16
|
+
}
|
|
17
|
+
if (typeof budget !== 'number' || !(budget > 0)) {
|
|
18
|
+
throw new Error(`Budget must be a positive number: ${budget}`);
|
|
19
|
+
}
|
|
20
|
+
const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
|
|
21
|
+
let timer;
|
|
22
|
+
let active = false;
|
|
23
|
+
async function tick() {
|
|
24
|
+
try {
|
|
25
|
+
await closer.close(budget);
|
|
26
|
+
} catch {
|
|
27
|
+
/* next tick retries */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async start() {
|
|
32
|
+
if (active) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
active = true;
|
|
36
|
+
await tick();
|
|
37
|
+
timer = setInterval(() => {
|
|
38
|
+
void tick();
|
|
39
|
+
}, intervalMs);
|
|
40
|
+
},
|
|
41
|
+
stop() {
|
|
42
|
+
if (!active) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
active = false;
|
|
46
|
+
clearInterval(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { alertIngestAction } from '../../../domain/alerting/ingest.js';
|
|
2
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PostgreSQL sink for alert records with INSERT/UPDATE branching.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} pool - pg.Pool instance with query() method
|
|
8
|
+
* @returns {object} Sink with accept() and stop() methods
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const sink = alertSink(pool);
|
|
12
|
+
* await sink.accept({ name: 'low_cosphi', message: 'msg', machine: 'm2', severity: 'warning', status: 'pending', timestamp: '2023-11-14T22:13:20.000Z' });
|
|
13
|
+
*/
|
|
14
|
+
export default function alertSink(pool) {
|
|
15
|
+
return {
|
|
16
|
+
async accept(record) {
|
|
17
|
+
try {
|
|
18
|
+
const action = alertIngestAction(record);
|
|
19
|
+
if (action === 'insert') {
|
|
20
|
+
await pool.query(
|
|
21
|
+
'INSERT INTO alerts (name, message, machine, severity, timestamp) VALUES ($1, $2, $3, $4, $5)',
|
|
22
|
+
[record.name, record.message, record.machine, record.severity, record.timestamp]
|
|
23
|
+
);
|
|
24
|
+
} else if (action === 'acknowledge') {
|
|
25
|
+
await pool.query(
|
|
26
|
+
'UPDATE alerts SET acknowledged = TRUE WHERE name = $1 AND machine = $2 AND acknowledged = FALSE',
|
|
27
|
+
[record.name, record.machine]
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
processingErrorLog('alert_sink_write', error, {
|
|
32
|
+
name: record.name,
|
|
33
|
+
machine: record.machine,
|
|
34
|
+
status: record.status
|
|
35
|
+
});
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async stop() {
|
|
40
|
+
await pool.end();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closes stale open segment rows for one machine before a new open segment lands.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} pool - pg Pool with query() method
|
|
7
|
+
* @returns {object} closer with close(machine, startTime)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const closer = closeOrphanOpen(pool);
|
|
11
|
+
* await closer.close('m1', '2024-01-01T00:00:00.000Z');
|
|
12
|
+
*/
|
|
13
|
+
export default function closeOrphanOpen(pool) {
|
|
14
|
+
if (!pool || typeof pool.query !== 'function') {
|
|
15
|
+
throw new Error('Pool must have a query() method');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
async close(machine, startTime) {
|
|
19
|
+
try {
|
|
20
|
+
await pool.query(
|
|
21
|
+
`UPDATE segments
|
|
22
|
+
SET end_time = start_time + interval '1 second', duration = 1
|
|
23
|
+
WHERE machine = $1 AND duration = 0 AND start_time <> $2`,
|
|
24
|
+
[machine, startTime]
|
|
25
|
+
);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
processingErrorLog('close_orphan_open', error, { machine, startTime });
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closes open segment rows whose advanced end boundary is older than a wall-clock silence budget.
|
|
5
|
+
* Requires end_time > start_time so a lone Started (end equals start) is never treated as silence.
|
|
6
|
+
* Does not insert a replacement Started row.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} pool - pg Pool with query() method
|
|
9
|
+
* @returns {object} closer with close(budgetSeconds)
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const closer = closeSilentOpen(pool);
|
|
13
|
+
* await closer.close(30);
|
|
14
|
+
*/
|
|
15
|
+
export default function closeSilentOpen(pool) {
|
|
16
|
+
if (!pool || typeof pool.query !== 'function') {
|
|
17
|
+
throw new Error('Pool must have a query() method');
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
async close(budgetSeconds) {
|
|
21
|
+
try {
|
|
22
|
+
await pool.query(
|
|
23
|
+
`UPDATE segments
|
|
24
|
+
SET duration = GREATEST(
|
|
25
|
+
1,
|
|
26
|
+
EXTRACT(EPOCH FROM (end_time - start_time))
|
|
27
|
+
)
|
|
28
|
+
WHERE duration = 0
|
|
29
|
+
AND end_time > start_time
|
|
30
|
+
AND end_time < NOW() - make_interval(secs => $1)`,
|
|
31
|
+
[budgetSeconds]
|
|
32
|
+
);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
processingErrorLog('close_silent_open', error, { budgetSeconds });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|