@yarkivaev/scada 1.4.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 -45
- package/package.json +28 -8
- 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/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,43 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sink for applying operator-assigned tags to an existing segment row.
|
|
5
|
+
*
|
|
6
|
+
* Executes a targeted UPDATE that sets tags, properties, clears options,
|
|
7
|
+
* and marks the segment as resolved. Does not touch name, duration, or
|
|
8
|
+
* timestamps, preserving the segment's structural identity.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const pool = new pg.Pool({ connectionString: url });
|
|
12
|
+
* const sink = retagSink(pool);
|
|
13
|
+
* sink.accept({ machine: 'm2', start_time: '2024-01-01T00:00:00.000Z',
|
|
14
|
+
* tags: '["charge_loading"]', properties: '{}' });
|
|
15
|
+
*
|
|
16
|
+
* @param {object} pool - pg Pool (or compatible) with query() method
|
|
17
|
+
* @returns {object} Sink with accept() method
|
|
18
|
+
*/
|
|
19
|
+
export default function retagSink(pool) {
|
|
20
|
+
if (!pool || typeof pool.query !== 'function') {
|
|
21
|
+
throw new Error('Pool must have a query() method');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
/**
|
|
25
|
+
* Applies tags and properties to the segment identified by machine and start_time.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} record - Record with machine, start_time, tags, properties
|
|
28
|
+
*/
|
|
29
|
+
async accept({ machine, start_time: startTime, tags, properties, options, resolved }) {
|
|
30
|
+
try {
|
|
31
|
+
const opts = options === undefined || options === null ? null : options;
|
|
32
|
+
const flag = typeof resolved === 'boolean' ? resolved : true;
|
|
33
|
+
await pool.query(
|
|
34
|
+
'UPDATE segments SET tags = $1, properties = $2, options = $3, resolved = $4 WHERE machine = $5 AND start_time = $6',
|
|
35
|
+
[tags, properties, opts, flag, machine, startTime]
|
|
36
|
+
);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
processingErrorLog('retag_sink_update', error, { machine, startTime, tags, properties });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sink for inserting user tag decisions into the user_decisions audit table.
|
|
5
|
+
*
|
|
6
|
+
* Each accepted record is persisted with machine, start_time, username,
|
|
7
|
+
* operator_id, decided_at, and the full raw JSON payload.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const sink = userDecisionSink(pool);
|
|
11
|
+
* sink.accept({ machine: 'm2', startTime: '2024-01-01T00:00:00.000Z',
|
|
12
|
+
* username: 'Ivan Petrov', operatorId: 2,
|
|
13
|
+
* decidedAt: '2024-01-01T00:01:00.000Z',
|
|
14
|
+
* payload: '{"tags":["charge_loading"]}' });
|
|
15
|
+
*
|
|
16
|
+
* @param {object} pool - pg Pool (or compatible) with query() method
|
|
17
|
+
* @returns {object} Sink with accept() method
|
|
18
|
+
*/
|
|
19
|
+
export default function userDecisionSink(pool) {
|
|
20
|
+
if (!pool || typeof pool.query !== 'function') {
|
|
21
|
+
throw new Error('Pool must have a query() method');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
/**
|
|
25
|
+
* Inserts a user decision record into the user_decisions table.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} record - machine, startTime, username, operatorId, decidedAt, payload
|
|
28
|
+
*/
|
|
29
|
+
async accept({ machine, startTime, username, operatorId, decidedAt, payload }) {
|
|
30
|
+
try {
|
|
31
|
+
await pool.query(
|
|
32
|
+
'INSERT INTO user_decisions (machine, start_time, username, operator_id, decided_at, payload) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
33
|
+
[machine, startTime, username, operatorId ?? null, decidedAt, payload]
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
processingErrorLog('decision_sink_insert', error, { machine, startTime, username, operatorId, decidedAt, payload });
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import amqp from 'amqplib';
|
|
2
|
+
import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
|
|
3
|
+
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
4
|
+
import metricsCodec from '../mqtt/metricsTransformer.js';
|
|
5
|
+
import streamNameFromTopic from './streamNameFromTopic.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Maps AMQP deliver to raw metrics message for metricsCodec.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} codec - metricsCodec instance
|
|
11
|
+
* @param {object} fields - AMQP deliver fields
|
|
12
|
+
* @param {Buffer} content - message body
|
|
13
|
+
* @param {Function} [onSeen] - optional callback(streamName) for edge freshness
|
|
14
|
+
*/
|
|
15
|
+
export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
|
|
16
|
+
const record = deliverToMqttRecord(fields, content);
|
|
17
|
+
if (typeof onSeen === 'function') {
|
|
18
|
+
const name = streamNameFromTopic(record.topic);
|
|
19
|
+
if (name) {
|
|
20
|
+
onSeen(name);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
codec.accept({ topic: record.topic, payload: record.payload.toString() });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds a queue consumer callback bound to one AMQP channel.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} codec - metricsCodec instance
|
|
30
|
+
* @param {object} channel - amqplib channel with ack(msg)
|
|
31
|
+
* @param {Function} [onSeen] - optional edge freshness callback
|
|
32
|
+
* @returns {Function} consume callback for ch.consume
|
|
33
|
+
*/
|
|
34
|
+
export function telemetryConsumer(codec, channel, onSeen) {
|
|
35
|
+
return (msg) => {
|
|
36
|
+
if (!msg) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
acceptTelemetryDeliver(codec, msg.fields, msg.content, onSeen);
|
|
40
|
+
channel.ack(msg);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* AMQP queue consumer that writes federated telemetry to a metrics sink.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} amqpUrl - RabbitMQ AMQP URL
|
|
48
|
+
* @param {string} queue - Durable queue name to consume
|
|
49
|
+
* @param {object} sink - sink with write(records)
|
|
50
|
+
* @param {object} [options] - batch and prefetch options
|
|
51
|
+
* @returns {object} ingest with start and stop
|
|
52
|
+
*/
|
|
53
|
+
export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
|
|
54
|
+
const prefetch = options.prefetch || 32;
|
|
55
|
+
const size = options.size || 100;
|
|
56
|
+
const interval = options.interval || 5;
|
|
57
|
+
const threshold = options.threshold || 5;
|
|
58
|
+
const timeout = options.timeout || 60;
|
|
59
|
+
let session;
|
|
60
|
+
const clk = clock();
|
|
61
|
+
const breaker = circuit(threshold, timeout, clk);
|
|
62
|
+
const collector = timedBatch(batch(sink, size, breaker), interval);
|
|
63
|
+
const codec = metricsCodec(collector);
|
|
64
|
+
return {
|
|
65
|
+
async start() {
|
|
66
|
+
if (session) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const conn = await amqp.connect(amqpUrl);
|
|
70
|
+
const ch = await conn.createChannel();
|
|
71
|
+
if (session) {
|
|
72
|
+
await ch.close();
|
|
73
|
+
await conn.close();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await ch.assertQueue(queue, { durable: true });
|
|
77
|
+
ch.prefetch(prefetch);
|
|
78
|
+
const onMessage = telemetryConsumer(codec, ch, options.onSeen);
|
|
79
|
+
const tag = await ch.consume(queue, onMessage, { noAck: false });
|
|
80
|
+
session = { conn, channel: ch, tag: tag.consumerTag };
|
|
81
|
+
},
|
|
82
|
+
async stop() {
|
|
83
|
+
const active = session;
|
|
84
|
+
if (!active) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
session = undefined;
|
|
88
|
+
await active.channel.cancel(active.tag);
|
|
89
|
+
await active.channel.close();
|
|
90
|
+
await active.conn.close();
|
|
91
|
+
collector.stop();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import amqp from 'amqplib';
|
|
2
|
+
import deliverToMqttRecord from './deliverToMqttRecord.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds a queue consumer callback bound to one AMQP channel.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} sink - sink with write(records)
|
|
8
|
+
* @param {object} channel - amqplib channel with ack(msg)
|
|
9
|
+
* @returns {Function} consume callback for ch.consume
|
|
10
|
+
*/
|
|
11
|
+
export function relayConsumer(sink, channel) {
|
|
12
|
+
return (msg) => {
|
|
13
|
+
if (!msg) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const record = deliverToMqttRecord(msg.fields, msg.content);
|
|
17
|
+
sink.write([record]);
|
|
18
|
+
channel.ack(msg);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AMQP queue consumer that republishes messages to MQTT using routing key as topic.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* import { mqttSink } from '@yarkivaev/source-to-sink';
|
|
27
|
+
* const sink = mqttSink('mqtt://localhost:1883', { qos: 1 });
|
|
28
|
+
* const relay = amqpMqttRelay('amqp://localhost', 'scada.telemetry.ingest', sink);
|
|
29
|
+
* relay.start();
|
|
30
|
+
*
|
|
31
|
+
* @param {string} amqpUrl - RabbitMQ AMQP URL
|
|
32
|
+
* @param {string} queue - Durable queue name to consume
|
|
33
|
+
* @param {object} sink - mqttSink with start, stop, write
|
|
34
|
+
* @param {object} [options] - prefetch count
|
|
35
|
+
* @returns {object} Relay with start and stop
|
|
36
|
+
*/
|
|
37
|
+
export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
|
|
38
|
+
const prefetch = options.prefetch || 32;
|
|
39
|
+
let session;
|
|
40
|
+
return {
|
|
41
|
+
async start() {
|
|
42
|
+
if (session) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const conn = await amqp.connect(amqpUrl);
|
|
46
|
+
const ch = await conn.createChannel();
|
|
47
|
+
if (session) {
|
|
48
|
+
await ch.close();
|
|
49
|
+
await conn.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await ch.assertQueue(queue, { durable: true });
|
|
53
|
+
ch.prefetch(prefetch);
|
|
54
|
+
sink.start();
|
|
55
|
+
const onMessage = relayConsumer(sink, ch);
|
|
56
|
+
const tag = await ch.consume(queue, onMessage, { noAck: false });
|
|
57
|
+
session = { conn, channel: ch, tag: tag.consumerTag };
|
|
58
|
+
},
|
|
59
|
+
async stop() {
|
|
60
|
+
const active = session;
|
|
61
|
+
if (!active) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
session = undefined;
|
|
65
|
+
await active.channel.cancel(active.tag);
|
|
66
|
+
await active.channel.close();
|
|
67
|
+
await active.conn.close();
|
|
68
|
+
sink.stop();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps AMQP deliver fields to MQTT publish record.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const record = deliverToMqttRecord({ routingKey: 'MX210.m-1.GET.AI1.VALUE' }, Buffer.from('1'));
|
|
6
|
+
* // record.topic === 'MX210/m-1/GET/AI1/VALUE'
|
|
7
|
+
*
|
|
8
|
+
* @param {object} fields - AMQP deliver fields with routingKey
|
|
9
|
+
* @param {Buffer|string} body - Raw message body
|
|
10
|
+
* @returns {object} Record with topic and payload for mqttSink
|
|
11
|
+
*/
|
|
12
|
+
export default function deliverToMqttRecord(fields, body) {
|
|
13
|
+
const { routingKey } = fields;
|
|
14
|
+
if (typeof routingKey !== 'string' || routingKey.length === 0) {
|
|
15
|
+
throw new Error('routing key is empty for telemetry deliver');
|
|
16
|
+
}
|
|
17
|
+
const topic = routingKey.replace(/\./gu, '/');
|
|
18
|
+
return { topic, payload: body };
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts the stream id from a metrics MQTT/AMQP topic path.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* streamNameFromTopic('MX210/m-1/GET/AI1/VALUE') === 'm-1'
|
|
6
|
+
*
|
|
7
|
+
* @param {string} topic - Slash-separated topic
|
|
8
|
+
* @returns {string|undefined} Device/stream id
|
|
9
|
+
*/
|
|
10
|
+
export default function streamNameFromTopic(topic) {
|
|
11
|
+
if (typeof topic !== 'string' || topic.length === 0) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const parts = topic.split('/').filter((part) => {
|
|
15
|
+
return part.length > 0;
|
|
16
|
+
});
|
|
17
|
+
if (parts.length < 2) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return parts[1];
|
|
21
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function trimBase(url) {
|
|
2
|
+
return String(url).replace(/\/$/u, '');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function authHeaders(token) {
|
|
6
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
7
|
+
if (token) {
|
|
8
|
+
headers.Authorization = `Bearer ${token}`;
|
|
9
|
+
}
|
|
10
|
+
return headers;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ownerError(message, code, status, cause) {
|
|
14
|
+
const err = new Error(message);
|
|
15
|
+
err.routeCode = code;
|
|
16
|
+
err.routeStatus = status;
|
|
17
|
+
if (cause) {
|
|
18
|
+
err.cause = cause;
|
|
19
|
+
}
|
|
20
|
+
return err;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readError(res) {
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
return text.length > 0 ? text : res.statusText;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readJson(res) {
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
if (!text || text.trim().length === 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return JSON.parse(text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HTTP operations write port that proxies create/update/delete to an edge plant API.
|
|
38
|
+
*
|
|
39
|
+
* Matches the owning-edge contract: central never upserts locally for edge machines.
|
|
40
|
+
* Failures surface as route errors (503 unreachable, 502 non-ok).
|
|
41
|
+
*
|
|
42
|
+
* @param {object} site - edge owner with baseUrl and optional token/fetch
|
|
43
|
+
* @param {string} machineId - machine identifier
|
|
44
|
+
* @returns {object} operations write port
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const port = httpOperations({ baseUrl: 'http://edge/api/v1' }, 'm2');
|
|
48
|
+
* await port.create({ kind: 'bath', payload: {}, operatorId: 2 });
|
|
49
|
+
*/
|
|
50
|
+
export default function httpOperations(site, machineId) {
|
|
51
|
+
const base = trimBase(site.baseUrl);
|
|
52
|
+
const fetcher = site.fetch || fetch;
|
|
53
|
+
async function send(method, path, body) {
|
|
54
|
+
const url = `${base}${path}`;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await fetcher(url, {
|
|
58
|
+
method,
|
|
59
|
+
headers: authHeaders(site.token),
|
|
60
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
61
|
+
});
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
throw ownerError(
|
|
64
|
+
`owner operations unreachable for ${machineId}: ${cause.message}`,
|
|
65
|
+
'SERVICE_UNAVAILABLE',
|
|
66
|
+
503,
|
|
67
|
+
cause
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw ownerError(
|
|
72
|
+
`owner operations ${method} ${path} for ${machineId} failed: ${res.status} ${await readError(res)}`,
|
|
73
|
+
'BAD_GATEWAY',
|
|
74
|
+
502
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return readJson(res);
|
|
78
|
+
}
|
|
79
|
+
const root = `/machines/${encodeURIComponent(machineId)}/operations`;
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
create(body) {
|
|
82
|
+
return send('POST', root, body);
|
|
83
|
+
},
|
|
84
|
+
update(key, body) {
|
|
85
|
+
return send('PUT', `${root}/${encodeURIComponent(key)}`, body);
|
|
86
|
+
},
|
|
87
|
+
remove(key, body) {
|
|
88
|
+
return send('DELETE', `${root}/${encodeURIComponent(key)}`, body);
|
|
89
|
+
},
|
|
90
|
+
decisions(key) {
|
|
91
|
+
return send('GET', `${root}/${encodeURIComponent(key)}/decisions`).then((body) => {
|
|
92
|
+
return Array.isArray(body && body.items) ? body.items : [];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
function trimBase(url) {
|
|
2
|
+
return String(url).replace(/\/$/u, '');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function authHeaders(token) {
|
|
6
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
7
|
+
if (token) {
|
|
8
|
+
headers.Authorization = `Bearer ${token}`;
|
|
9
|
+
}
|
|
10
|
+
return headers;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeBody(start, tags, properties, audit) {
|
|
14
|
+
const body = {
|
|
15
|
+
start: start.toISOString(),
|
|
16
|
+
tags: tags || [],
|
|
17
|
+
properties: properties || {}
|
|
18
|
+
};
|
|
19
|
+
if (audit && audit.id !== undefined && audit.id !== null) {
|
|
20
|
+
body.operatorId = audit.id;
|
|
21
|
+
}
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ownerError(message, code, status, cause) {
|
|
26
|
+
const err = new Error(message);
|
|
27
|
+
err.routeCode = code;
|
|
28
|
+
err.routeStatus = status;
|
|
29
|
+
if (cause) {
|
|
30
|
+
err.cause = cause;
|
|
31
|
+
}
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readError(res) {
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
return text.length > 0 ? text : res.statusText;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* HTTP timeline write port that proxies retag/respond to an edge plant API.
|
|
42
|
+
*
|
|
43
|
+
* Matches stompTimeline shape: retag(start, tags, properties, audit) and
|
|
44
|
+
* respond(start, tags, properties, audit). Failures surface as route errors.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} site - edge owner with baseUrl and optional token/fetch
|
|
47
|
+
* @param {string} machineId - machine identifier
|
|
48
|
+
* @returns {object} timeline write port
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const port = httpTimeline({ baseUrl: 'http://edge/api/v1' }, 'm2');
|
|
52
|
+
* await port.retag(start, ['heat'], {}, audit);
|
|
53
|
+
*/
|
|
54
|
+
export default function httpTimeline(site, machineId) {
|
|
55
|
+
const base = trimBase(site.baseUrl);
|
|
56
|
+
const fetcher = site.fetch || fetch;
|
|
57
|
+
async function send(method, path, body) {
|
|
58
|
+
const url = `${base}${path}`;
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetcher(url, {
|
|
62
|
+
method,
|
|
63
|
+
headers: authHeaders(site.token),
|
|
64
|
+
body: JSON.stringify(body)
|
|
65
|
+
});
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
throw ownerError(
|
|
68
|
+
`owner timeline unreachable for ${machineId}: ${cause.message}`,
|
|
69
|
+
'SERVICE_UNAVAILABLE',
|
|
70
|
+
503,
|
|
71
|
+
cause
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
throw ownerError(
|
|
76
|
+
`owner timeline ${method} ${path} for ${machineId} failed: ${res.status} ${await readError(res)}`,
|
|
77
|
+
'BAD_GATEWAY',
|
|
78
|
+
502
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return res;
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
async retag(start, tags, properties, audit) {
|
|
85
|
+
await send('PATCH', `/machines/${encodeURIComponent(machineId)}/segments`, writeBody(start, tags, properties, audit));
|
|
86
|
+
},
|
|
87
|
+
async respond(start, tags, properties, audit) {
|
|
88
|
+
const requestId = encodeURIComponent(start.toISOString());
|
|
89
|
+
const body = {
|
|
90
|
+
tags: tags || [],
|
|
91
|
+
properties: properties || {}
|
|
92
|
+
};
|
|
93
|
+
if (audit && audit.id !== undefined && audit.id !== null) {
|
|
94
|
+
body.operatorId = audit.id;
|
|
95
|
+
}
|
|
96
|
+
await send('POST', `/machines/${encodeURIComponent(machineId)}/requests/${requestId}/respond`, body);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a machineId → owner registry from EDGE_SITES-style site lists.
|
|
3
|
+
*
|
|
4
|
+
* Unknown machines resolve to local (STOMP) ownership.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<{baseUrl: string, token?: string, machines: string[]}>} sites - edge sites
|
|
7
|
+
* @returns {object} frozen registry with resolve(machineId)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const owners = machineOwners([
|
|
11
|
+
* { baseUrl: 'http://edge:3000/api/v1', token: 'secret', machines: ['m2'] }
|
|
12
|
+
* ]);
|
|
13
|
+
* owners.resolve('m2'); // { kind: 'edge', baseUrl, token }
|
|
14
|
+
* owners.resolve('m1'); // { kind: 'local' }
|
|
15
|
+
*/
|
|
16
|
+
export default function machineOwners(sites) {
|
|
17
|
+
const map = new Map();
|
|
18
|
+
for (const site of sites || []) {
|
|
19
|
+
const owner = Object.freeze({
|
|
20
|
+
kind: 'edge',
|
|
21
|
+
baseUrl: site.baseUrl,
|
|
22
|
+
token: site.token
|
|
23
|
+
});
|
|
24
|
+
for (const id of site.machines || []) {
|
|
25
|
+
map.set(id, owner);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
/**
|
|
30
|
+
* Resolves ownership for one machine.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} machineId - machine identifier
|
|
33
|
+
* @returns {object} { kind: 'local' } or { kind: 'edge', baseUrl, token }
|
|
34
|
+
*/
|
|
35
|
+
resolve(machineId) {
|
|
36
|
+
return map.get(machineId) || Object.freeze({ kind: 'local' });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import httpTimeline from './httpTimeline.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owner-routed timeline write factory.
|
|
5
|
+
*
|
|
6
|
+
* Local machines use the injected local write port (typically stompTimeline);
|
|
7
|
+
* edge-owned machines proxy PATCH/respond to the owning plant API.
|
|
8
|
+
*
|
|
9
|
+
* @param {function(string): object} localTimeline - factory (machineId) => write port
|
|
10
|
+
* @param {object} owners - registry with resolve(machineId) → local | edge owner
|
|
11
|
+
* @returns {function(string): object} factory (machineId) => write port
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const write = ownerTimeline(
|
|
15
|
+
* (id) => stompTimeline(decisions, id),
|
|
16
|
+
* machineOwners([{ baseUrl: 'http://edge/api/v1', machines: ['m2'] }])
|
|
17
|
+
* );
|
|
18
|
+
* await write('m2').retag(start, ['heat'], {}, audit);
|
|
19
|
+
*/
|
|
20
|
+
export default function ownerTimeline(localTimeline, owners) {
|
|
21
|
+
return function forMachine(machineId) {
|
|
22
|
+
const owner = owners.resolve(machineId);
|
|
23
|
+
if (owner.kind === 'edge') {
|
|
24
|
+
return httpTimeline(owner, machineId);
|
|
25
|
+
}
|
|
26
|
+
return localTimeline(machineId);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only alert collection backed by PG hydration and STOMP.
|
|
3
|
+
*
|
|
4
|
+
* Loads unacknowledged alerts from PostgreSQL on init, subscribes to
|
|
5
|
+
* STOMP for real-time in-memory updates and SSE delivery.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} hydrate - pgAlerts or compatible port
|
|
8
|
+
* @param {function} source - factory returning STOMP source with start/stop
|
|
9
|
+
* @param {object} translations - map of rule names to human-readable messages
|
|
10
|
+
* @returns {object} alert collection with init, all, find, stream, trigger, stop methods
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const history = stompAlerts(pgAlerts(pool), sourceFactory, { low_cosphi: 'Switch off...' });
|
|
14
|
+
* await history.init();
|
|
15
|
+
* history.all();
|
|
16
|
+
*/
|
|
17
|
+
import stompAlertsCollection from './stompAlertsCollection.js';
|
|
18
|
+
|
|
19
|
+
export default function stompAlerts(hydrate, source, translations) {
|
|
20
|
+
return stompAlertsCollection(hydrate, source, translations);
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pubsub from '../../../../domain/shared/pubsub.js';
|
|
2
|
+
import stompAlertsInit from './stompAlertsInit.js';
|
|
3
|
+
import { build, consume } from './stompAlertsLogic.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} hydrate - alert hydration port
|
|
7
|
+
* @param {function} source - STOMP source factory
|
|
8
|
+
* @param {object} translations - rule name map
|
|
9
|
+
* @returns {object} alerts collection
|
|
10
|
+
*/
|
|
11
|
+
export default function stompAlertsCollection(hydrate, source, translations) {
|
|
12
|
+
const items = [];
|
|
13
|
+
const bus = pubsub();
|
|
14
|
+
const state = { items, bus, counter: 0 };
|
|
15
|
+
let subscription = null;
|
|
16
|
+
return {
|
|
17
|
+
async init() {
|
|
18
|
+
await stompAlertsInit(hydrate, items, state);
|
|
19
|
+
subscription = source({
|
|
20
|
+
accept(raw) {
|
|
21
|
+
consume(raw, state, translations);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
trigger(message, timestamp, object, name) {
|
|
26
|
+
state.counter += 1;
|
|
27
|
+
const alert = build({ id: state.counter, message, timestamp, machine: object, acknowledged: false, name });
|
|
28
|
+
items.push(alert);
|
|
29
|
+
bus.emit({ type: 'created', alert });
|
|
30
|
+
return alert;
|
|
31
|
+
},
|
|
32
|
+
all(...filters) {
|
|
33
|
+
return items.filter((a) => {
|
|
34
|
+
return filters.every((filter) => {
|
|
35
|
+
return filter(a);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
find(id) {
|
|
40
|
+
return items.find((a) => {
|
|
41
|
+
return a.id === id;
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
stream: bus.stream,
|
|
45
|
+
stop() {
|
|
46
|
+
if (subscription) {
|
|
47
|
+
subscription.stop();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { build } from './stompAlertsLogic.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads initial alerts from a hydration port into memory.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} hydrate - port with listUnacknowledged
|
|
7
|
+
* @param {Array} items - mutable items array
|
|
8
|
+
* @param {object} state - shared state with counter
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
11
|
+
export default async function stompAlertsInit(hydrate, items, state) {
|
|
12
|
+
const rows = await hydrate.listUnacknowledged({});
|
|
13
|
+
rows.forEach((row) => {
|
|
14
|
+
items.push(build({
|
|
15
|
+
id: row.id,
|
|
16
|
+
message: row.message,
|
|
17
|
+
timestamp: row.timestamp,
|
|
18
|
+
machine: row.machine,
|
|
19
|
+
acknowledged: row.acknowledged,
|
|
20
|
+
name: row.name
|
|
21
|
+
}));
|
|
22
|
+
state.counter = Math.max(state.counter, row.id);
|
|
23
|
+
});
|
|
24
|
+
}
|