@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulls one topic from Query for the given scope.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} query - exportQuery port
|
|
5
|
+
* @param {object} scope - { kind, machine, from, to, step, keys, ... }
|
|
6
|
+
* @returns {Promise<*>} topic payload
|
|
7
|
+
*/
|
|
8
|
+
function pull(query, scope) {
|
|
9
|
+
const { kind } = scope;
|
|
10
|
+
if (kind === 'machines') {
|
|
11
|
+
return query.machines();
|
|
12
|
+
}
|
|
13
|
+
if (kind === 'measurements') {
|
|
14
|
+
return query.measurements(scope.machine, scope);
|
|
15
|
+
}
|
|
16
|
+
if (kind === 'segments') {
|
|
17
|
+
return query.segments(scope.machine, scope);
|
|
18
|
+
}
|
|
19
|
+
if (kind === 'alerts') {
|
|
20
|
+
return query.alerts(scope.machine, scope);
|
|
21
|
+
}
|
|
22
|
+
if (kind === 'operations') {
|
|
23
|
+
return query.operations(scope.machine, scope);
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`export job cannot pull unknown kind ${kind}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Opens one Stream subscription for the given scope.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} stream - exportStream port
|
|
32
|
+
* @param {object} scope - { kind, machine, ... }
|
|
33
|
+
* @returns {object} connection with on/close
|
|
34
|
+
*/
|
|
35
|
+
function open(stream, scope) {
|
|
36
|
+
const { kind } = scope;
|
|
37
|
+
if (kind === 'measurements') {
|
|
38
|
+
return stream.measurements(scope.machine, scope);
|
|
39
|
+
}
|
|
40
|
+
if (kind === 'segments') {
|
|
41
|
+
return stream.segments(scope.machine);
|
|
42
|
+
}
|
|
43
|
+
if (kind === 'alerts') {
|
|
44
|
+
return stream.alerts(scope.machine);
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'operations') {
|
|
47
|
+
return stream.operations(scope.machine);
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`export job cannot pipe unknown kind ${kind}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default SSE event names for a stream kind.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} kind - stream topic
|
|
56
|
+
* @returns {string[]} event names
|
|
57
|
+
*/
|
|
58
|
+
function defaultEvents(kind) {
|
|
59
|
+
if (kind === 'measurements') {
|
|
60
|
+
return ['measurement'];
|
|
61
|
+
}
|
|
62
|
+
if (kind === 'segments') {
|
|
63
|
+
return ['segment_created', 'segment_resolved'];
|
|
64
|
+
}
|
|
65
|
+
if (kind === 'alerts') {
|
|
66
|
+
return ['alert'];
|
|
67
|
+
}
|
|
68
|
+
if (kind === 'operations') {
|
|
69
|
+
return ['operation_created', 'operation_updated'];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Export Job: pulls Query or Stream, transforms, then writes to Sink.
|
|
76
|
+
*
|
|
77
|
+
* Query path uses run(scope). Stream path uses pipe(scope) and returns stop().
|
|
78
|
+
* Transform and destination wiring belong to the caller (plant packages).
|
|
79
|
+
*
|
|
80
|
+
* @param {object} ports - { query?, stream?, transform?, sink }
|
|
81
|
+
* @returns {object} frozen job with run and pipe
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const job = exportJob({ query, transform: (rows) => rows, sink });
|
|
85
|
+
* await job.run({ kind: 'segments', machine: 'furnace-α', from, to });
|
|
86
|
+
*
|
|
87
|
+
* const live = exportJob({ stream, transform: (rows) => rows, sink });
|
|
88
|
+
* const handle = live.pipe({ kind: 'segments', machine: 'furnace-α', events: ['segment_created'] });
|
|
89
|
+
* handle.stop();
|
|
90
|
+
*/
|
|
91
|
+
export default function exportJob(ports) {
|
|
92
|
+
const transform = ports.transform || function identity(rows) {
|
|
93
|
+
return rows;
|
|
94
|
+
};
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
async run(scope) {
|
|
97
|
+
const rows = await pull(ports.query, scope);
|
|
98
|
+
const batch = await Promise.resolve(transform(rows));
|
|
99
|
+
return ports.sink.write(batch);
|
|
100
|
+
},
|
|
101
|
+
pipe(scope) {
|
|
102
|
+
const conn = open(ports.stream, scope);
|
|
103
|
+
const events = scope.events || defaultEvents(scope.kind);
|
|
104
|
+
events.forEach((name) => {
|
|
105
|
+
conn.on(name, (payload) => {
|
|
106
|
+
Promise.resolve(transform([payload])).then((batch) => {
|
|
107
|
+
return ports.sink.write(batch);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
stop() {
|
|
113
|
+
conn.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand export Query port over plantApi via scadaClient.
|
|
3
|
+
*
|
|
4
|
+
* Reads machines, measurements, segments, alerts, and operations by range.
|
|
5
|
+
* Does not talk to storage directly and carries no plant-specific names.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} client - scadaClient (or fake) with machines() and machine(id)
|
|
8
|
+
* @returns {object} frozen query with machines, measurements, segments, alerts, operations
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const query = exportQuery(scadaClient(url, fetch, EventSource));
|
|
12
|
+
* const rows = await query.segments('furnace-α', { from, to });
|
|
13
|
+
*/
|
|
14
|
+
export default function exportQuery(client) {
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
machines() {
|
|
17
|
+
return client.machines();
|
|
18
|
+
},
|
|
19
|
+
measurements(machineId, range) {
|
|
20
|
+
return client.machine(machineId).measurements(range);
|
|
21
|
+
},
|
|
22
|
+
segments(machineId, range) {
|
|
23
|
+
return client.machine(machineId).segments(range);
|
|
24
|
+
},
|
|
25
|
+
alerts(machineId, options) {
|
|
26
|
+
return client.machine(machineId).alerts(options || {});
|
|
27
|
+
},
|
|
28
|
+
operations(machineId, options) {
|
|
29
|
+
return client.machine(machineId).operations(options || {});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic export Sink port for outbound batches and artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Destination adapters (HTTP, file, plant integrations) live outside this package.
|
|
5
|
+
* This factory only freezes the write/send contract.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} destination - object with write(records) and/or send(artifact)
|
|
8
|
+
* @returns {object} frozen sink with write and send
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const sink = exportSink({
|
|
12
|
+
* write(records) { return http.post('/hook', records); },
|
|
13
|
+
* send(artifact) { return http.post('/doc', artifact); }
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
export default function exportSink(destination) {
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
write(records) {
|
|
19
|
+
return destination.write(records);
|
|
20
|
+
},
|
|
21
|
+
send(artifact) {
|
|
22
|
+
return destination.send(artifact);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming export Stream port over plantApi SSE via scadaClient.
|
|
3
|
+
*
|
|
4
|
+
* Opens measurement, segment, alert, and operation subscriptions.
|
|
5
|
+
* Returns the same connection shape as scada/client (on/close).
|
|
6
|
+
*
|
|
7
|
+
* @param {object} client - scadaClient (or fake) with machine(id) stream methods
|
|
8
|
+
* @returns {object} frozen stream with measurements, segments, alerts, operations
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const stream = exportStream(client);
|
|
12
|
+
* const conn = stream.segments('furnace-α');
|
|
13
|
+
* conn.on('segment_created', (row) => { sink.write([row]); });
|
|
14
|
+
*/
|
|
15
|
+
export default function exportStream(client) {
|
|
16
|
+
return Object.freeze({
|
|
17
|
+
measurements(machineId, options) {
|
|
18
|
+
return client.machine(machineId).measurementStream(options || {});
|
|
19
|
+
},
|
|
20
|
+
segments(machineId) {
|
|
21
|
+
return client.machine(machineId).segmentStream();
|
|
22
|
+
},
|
|
23
|
+
alerts(machineId) {
|
|
24
|
+
return client.machine(machineId).alertStream();
|
|
25
|
+
},
|
|
26
|
+
operations(machineId, notify) {
|
|
27
|
+
const listen = notify || ((payload) => {
|
|
28
|
+
return payload;
|
|
29
|
+
});
|
|
30
|
+
return client.machine(machineId).operationsStream(listen);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates a machine inside a plant by id.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} plant - plant with shops
|
|
5
|
+
* @param {string} id - machine id
|
|
6
|
+
* @returns {object|undefined} machine and shop or undefined
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const found = machineInPlant(plant, 'm1');
|
|
10
|
+
* found.machine.sensors;
|
|
11
|
+
*/
|
|
12
|
+
export default function machineInPlant(plant, id) {
|
|
13
|
+
for (const area of Object.values(plant.shops.get())) {
|
|
14
|
+
const found = area.machines.get()[id];
|
|
15
|
+
if (found) {
|
|
16
|
+
return { machine: found, shop: area };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import plant from '../domain/plant/plant.js';
|
|
2
|
+
import initialized from '../domain/shared/initialized.js';
|
|
3
|
+
import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic plant factory wiring sensors and a shop factory over a connection.
|
|
7
|
+
*
|
|
8
|
+
* @param {Array<{device: string, machine: string}>} machines - machine descriptors
|
|
9
|
+
* @param {object} options - connection, buildSensors, shopFactory, pool, alerts, userDecisions, shopName
|
|
10
|
+
* @returns {Promise<object>} plant with shops and init
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const p = await metricsPlant(machines, {
|
|
14
|
+
* connection: () => clickhouseConnection(host, opts),
|
|
15
|
+
* buildSensors: (conn, entry) => ({ voltage: sensor(conn, entry) }),
|
|
16
|
+
* shopFactory: genericShop
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
export default async function metricsPlant(machines, options) {
|
|
20
|
+
const connection = await options.connection();
|
|
21
|
+
const pool = postgresPool(options);
|
|
22
|
+
const entries = machines.map((entry) => {
|
|
23
|
+
return {
|
|
24
|
+
name: entry.machine,
|
|
25
|
+
sensors: options.buildSensors(connection, entry)
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
const shop = options.shopFactory(entries, {
|
|
29
|
+
pool,
|
|
30
|
+
alerts: options.alerts,
|
|
31
|
+
userDecisions: options.userDecisions,
|
|
32
|
+
shopName: options.shopName
|
|
33
|
+
});
|
|
34
|
+
return plant(initialized({ [shop.name()]: shop }, Object.values));
|
|
35
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import machineRoute from '../infrastructure/http/plant/routes/machineRoute.js';
|
|
2
|
+
import measurementRoute from '../infrastructure/http/plant/routes/measurementRoute.js';
|
|
3
|
+
import measurementStream from '../infrastructure/http/plant/streams/measurementStream.js';
|
|
4
|
+
import alertRoute from '../infrastructure/http/plant/routes/alertRoute.js';
|
|
5
|
+
import alertStream from '../infrastructure/http/plant/streams/alertStream.js';
|
|
6
|
+
import timelineRoute from '../infrastructure/http/plant/routes/timelineRoute.js';
|
|
7
|
+
import timelineStream from '../infrastructure/http/plant/streams/timelineStream.js';
|
|
8
|
+
import operationRoute from '../infrastructure/http/plant/routes/operationRoute.js';
|
|
9
|
+
import operationStream from '../infrastructure/http/plant/streams/operationStream.js';
|
|
10
|
+
import heartbeatStream from '../infrastructure/http/plant/streams/heartbeatStream.js';
|
|
11
|
+
import simulationRoute from '../infrastructure/http/plant/routes/simulationRoute.js';
|
|
12
|
+
import catalogRoute from '../infrastructure/http/plant/routes/catalogRoute.js';
|
|
13
|
+
import { routes } from '@yarkivaev/simple-server';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Composable plant HTTP API factory.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} basePath - base URL path
|
|
19
|
+
* @param {object} plant - plant domain object with operations (optional kindSources at construction)
|
|
20
|
+
* @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat
|
|
21
|
+
* @returns {object} routes with list() and handle()
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const api = plantApi('/api/v1', plant, { extraRoutes: siteRoute('/api/v1', plant) });
|
|
25
|
+
*/
|
|
26
|
+
export default function plantApi(basePath, plant, config) {
|
|
27
|
+
const opts = config || {};
|
|
28
|
+
plant.init();
|
|
29
|
+
const time = opts.clock || (() => {
|
|
30
|
+
return new Date();
|
|
31
|
+
});
|
|
32
|
+
const extra = opts.extraRoutes || [];
|
|
33
|
+
const routeList = [
|
|
34
|
+
...catalogRoute(basePath, opts.tagCatalog),
|
|
35
|
+
...machineRoute(basePath, plant),
|
|
36
|
+
...measurementStream(basePath, plant, time),
|
|
37
|
+
...measurementRoute(basePath, plant, time),
|
|
38
|
+
...alertStream(basePath, plant, time),
|
|
39
|
+
...alertRoute(basePath, plant),
|
|
40
|
+
...timelineRoute(basePath, plant, opts.timelineOperator),
|
|
41
|
+
...timelineStream(basePath, plant, time),
|
|
42
|
+
...operationRoute(basePath, plant, opts.timelineOperator, opts.operationDecisions, opts.owners),
|
|
43
|
+
...operationStream(basePath, plant, time),
|
|
44
|
+
...heartbeatStream(basePath, time, opts.heartbeat),
|
|
45
|
+
...(time.jump ? simulationRoute(basePath, time) : []),
|
|
46
|
+
...extra
|
|
47
|
+
];
|
|
48
|
+
return routes(routeList, { requestTimeoutMs: opts.requestTimeoutMs });
|
|
49
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import operations from '../domain/operation/operations.js';
|
|
2
|
+
import pubsub from '../domain/shared/pubsub.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wires operations persistence to domain operations with SSE pubsub.
|
|
6
|
+
*
|
|
7
|
+
* Optional kindSources inject non-PG kinds into listForMachine merges.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} persistence - operations store with upsert, get, remove, listForMachine
|
|
10
|
+
* @param {object} [kindSources] - map of kind to { list(machineId, range) }
|
|
11
|
+
* @returns {object} operations with listForMachine, upsert, get, remove, and stream
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const ops = plantOperations(dataAccess.operations, { temp: temperaturePort });
|
|
15
|
+
* plant(shops, { operations: ops });
|
|
16
|
+
*/
|
|
17
|
+
export default function plantOperations(persistence, kindSources) {
|
|
18
|
+
return operations(persistence, pubsub(), kindSources);
|
|
19
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { stompSource } from '@yarkivaev/source-to-sink';
|
|
3
|
+
import plantApi from './plantApi.js';
|
|
4
|
+
import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
|
|
5
|
+
import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
6
|
+
import stompAlerts from '../infrastructure/messaging/stomp/alerts/stompAlerts.js';
|
|
7
|
+
import stompTimelineSegments from '../infrastructure/messaging/stomp/stompTimelineSegments.js';
|
|
8
|
+
import userDecisions from '../infrastructure/messaging/stomp/userDecisions.js';
|
|
9
|
+
import { parseRequestTimeoutMs, virtualClock } from '@yarkivaev/simple-server';
|
|
10
|
+
|
|
11
|
+
function stompCollectorFactory(stompUrl, destination, credentials) {
|
|
12
|
+
return (collector) => {
|
|
13
|
+
const src = stompSource(stompUrl, destination, collector, credentials);
|
|
14
|
+
src.start();
|
|
15
|
+
return src;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function timelineBusesInPlant(p) {
|
|
20
|
+
const buses = {};
|
|
21
|
+
for (const area of Object.values(p.shops.get())) {
|
|
22
|
+
for (const [id, item] of Object.entries(area.machines.get())) {
|
|
23
|
+
if (item.timeline && item.timeline.bus) {
|
|
24
|
+
buses[id] = item.timeline.bus;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return buses;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function wireTimelineSegments(stomp, plant) {
|
|
32
|
+
if (!stomp || !stomp.url) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const buses = timelineBusesInPlant(plant);
|
|
36
|
+
if (Object.keys(buses).length === 0) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const credentials = { login: stomp.login, passcode: stomp.passcode, host: stomp.host };
|
|
40
|
+
return stompTimelineSegments(
|
|
41
|
+
stompCollectorFactory(stomp.url, '/exchange/scada.segments', credentials),
|
|
42
|
+
buses
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function initStomp(stomp, translations, requirePool) {
|
|
47
|
+
if (!stomp || !stomp.url) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
const pool = postgresPool({});
|
|
51
|
+
if (requirePool && !pool) {
|
|
52
|
+
throw new Error('SUPERVISOR_STATE_PG_URL is required for plant server alerts');
|
|
53
|
+
}
|
|
54
|
+
const credentials = { login: stomp.login, passcode: stomp.passcode, host: stomp.host };
|
|
55
|
+
let alerts;
|
|
56
|
+
if (pool) {
|
|
57
|
+
alerts = stompAlerts(pgAlerts(pool), stompCollectorFactory(stomp.url, '/exchange/scada.alerts', credentials), translations || {});
|
|
58
|
+
await alerts.init();
|
|
59
|
+
}
|
|
60
|
+
const decisions = userDecisions({
|
|
61
|
+
stompUrl: stomp.url,
|
|
62
|
+
login: stomp.login,
|
|
63
|
+
passcode: stomp.passcode,
|
|
64
|
+
host: stomp.host
|
|
65
|
+
});
|
|
66
|
+
return { alerts, userDecisions: decisions };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generic HTTP plant server with optional STOMP alerts and user decisions.
|
|
71
|
+
*
|
|
72
|
+
* Multi-kind operations come from plant.operations built with kindSources
|
|
73
|
+
* (e.g. plantOperations(persistence, { temp })).
|
|
74
|
+
*
|
|
75
|
+
* @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool
|
|
76
|
+
* @returns {Promise<object>} server, plant, api, segments
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* await plantServer({ port: 3000, basePath: '/api/v1', plantFactory, extraRoutes });
|
|
80
|
+
*/
|
|
81
|
+
export default async function plantServer(config) {
|
|
82
|
+
const { alerts, userDecisions: decisions } = await initStomp(config.stomp, config.translations, config.requirePool);
|
|
83
|
+
const p = await config.plantFactory({ alerts, userDecisions: decisions });
|
|
84
|
+
const segments = wireTimelineSegments(config.stomp, p);
|
|
85
|
+
const clock = virtualClock(() => {
|
|
86
|
+
return new Date();
|
|
87
|
+
});
|
|
88
|
+
const requestTimeoutMs = parseRequestTimeoutMs(process.env.REQUEST_TIMEOUT_MS);
|
|
89
|
+
const basePath = config.basePath || '/api/v1';
|
|
90
|
+
const extra = config.extraRoutes ? config.extraRoutes(basePath, p, clock) : [];
|
|
91
|
+
const api = plantApi(basePath, p, {
|
|
92
|
+
clock,
|
|
93
|
+
heartbeat: config.heartbeat || 1000,
|
|
94
|
+
requestTimeoutMs,
|
|
95
|
+
extraRoutes: extra,
|
|
96
|
+
timelineOperator: config.timelineOperator,
|
|
97
|
+
operationDecisions: config.operationDecisions,
|
|
98
|
+
owners: config.owners
|
|
99
|
+
});
|
|
100
|
+
const server = http.createServer((req, res) => {
|
|
101
|
+
return api.handle(req, res);
|
|
102
|
+
});
|
|
103
|
+
await new Promise((resolve) => {
|
|
104
|
+
server.listen(config.port, resolve);
|
|
105
|
+
});
|
|
106
|
+
return { server, plant: p, api, segments };
|
|
107
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import pubsub from '../domain/shared/pubsub.js';
|
|
2
|
+
import timeline from '../domain/timeline/timeline.js';
|
|
3
|
+
import pgTimeline from '../infrastructure/persistence/pg/timeline.js';
|
|
4
|
+
import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persistence/memory/timeline.js';
|
|
5
|
+
import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
|
|
6
|
+
import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
|
|
7
|
+
|
|
8
|
+
function parseStart(requestId) {
|
|
9
|
+
const raw = decodeURIComponent(String(requestId));
|
|
10
|
+
const start = new Date(raw);
|
|
11
|
+
if (Number.isNaN(start.getTime())) {
|
|
12
|
+
throw new RangeError(`Invalid request start time ${raw}`);
|
|
13
|
+
}
|
|
14
|
+
return start;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolvedStub(start, tags, properties) {
|
|
18
|
+
return {
|
|
19
|
+
name: '',
|
|
20
|
+
start_time: start,
|
|
21
|
+
end_time: start,
|
|
22
|
+
duration: 0,
|
|
23
|
+
tags: JSON.stringify(tags),
|
|
24
|
+
properties: JSON.stringify(properties)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function memoryTimelinePort(store, bus) {
|
|
29
|
+
const port = memoryTimelineRead(store, bus);
|
|
30
|
+
return {
|
|
31
|
+
list: port.list,
|
|
32
|
+
rowAt: port.rowAt,
|
|
33
|
+
pending: port.pending,
|
|
34
|
+
stream: port.stream,
|
|
35
|
+
bus,
|
|
36
|
+
retag(start, tags, properties, audit) {
|
|
37
|
+
const found = store.items.find((item) => {
|
|
38
|
+
return item.start_time.getTime() === start.getTime();
|
|
39
|
+
});
|
|
40
|
+
if (found) {
|
|
41
|
+
found.tags = tags;
|
|
42
|
+
found.properties = properties;
|
|
43
|
+
delete found.options;
|
|
44
|
+
}
|
|
45
|
+
bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
|
|
46
|
+
},
|
|
47
|
+
respond(requestId, body, audit) {
|
|
48
|
+
const start = parseStart(requestId);
|
|
49
|
+
const found = store.pending.find((item) => {
|
|
50
|
+
return item.start_time.getTime() === start.getTime();
|
|
51
|
+
});
|
|
52
|
+
if (found) {
|
|
53
|
+
found.resolved = true;
|
|
54
|
+
found.tags = body.tags;
|
|
55
|
+
found.properties = body.properties;
|
|
56
|
+
}
|
|
57
|
+
bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
|
|
58
|
+
return { id: requestId, ...body };
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writePort(name, decisions, owners) {
|
|
64
|
+
if (!owners) {
|
|
65
|
+
return stompTimeline(decisions, name);
|
|
66
|
+
}
|
|
67
|
+
return ownerTimeline((id) => {
|
|
68
|
+
return stompTimeline(decisions, id);
|
|
69
|
+
}, owners)(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
|
|
74
|
+
*
|
|
75
|
+
* Optional owners registry routes retag/respond to an edge HTTP plant API.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} name - machine id
|
|
78
|
+
* @param {object} options - pool, userDecisions, optional owners
|
|
79
|
+
* @returns {object} timeline port with list, rowAt, pending, stream, retag, respond, bus
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
|
|
83
|
+
*/
|
|
84
|
+
export default function shopWithTimeline(name, options) {
|
|
85
|
+
const { pool, userDecisions: decisions, owners } = options;
|
|
86
|
+
if (pool && decisions) {
|
|
87
|
+
const bus = pubsub();
|
|
88
|
+
const read = pgTimeline(pool, name);
|
|
89
|
+
const write = writePort(name, decisions, owners);
|
|
90
|
+
const port = timeline(read, bus);
|
|
91
|
+
return {
|
|
92
|
+
list: port.list,
|
|
93
|
+
rowAt: port.rowAt,
|
|
94
|
+
pending: port.pending,
|
|
95
|
+
stream: port.stream,
|
|
96
|
+
bus,
|
|
97
|
+
async retag(start, tags, properties, audit) {
|
|
98
|
+
await write.retag(start, tags, properties, audit);
|
|
99
|
+
bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
|
|
100
|
+
},
|
|
101
|
+
async respond(requestId, body, audit) {
|
|
102
|
+
const start = parseStart(requestId);
|
|
103
|
+
await write.respond(start, body.tags, body.properties || {}, audit);
|
|
104
|
+
bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
|
|
105
|
+
return { id: requestId, ...body };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const store = memoryTimelineStore();
|
|
110
|
+
const bus = pubsub();
|
|
111
|
+
return memoryTimelinePort(store, bus);
|
|
112
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import operatorsFromPg from '../infrastructure/persistence/pg/operators.js';
|
|
2
|
+
import userDecisionsFromPg from '../infrastructure/persistence/pg/userDecisions.js';
|
|
3
|
+
import operatorRoute from '../infrastructure/http/plant/routes/operatorRoute.js';
|
|
4
|
+
import decisionRoute from '../infrastructure/http/plant/routes/decisionRoute.js';
|
|
5
|
+
import edgeOperatorCatalog from './edgeOperatorCatalog.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Central-only operator and user_decisions routes backed by Postgres.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} basePath - plant API base path
|
|
11
|
+
* @param {object} sink - supervisor sink with pool and profile
|
|
12
|
+
* @returns {object} routes, provider, decisions
|
|
13
|
+
*/
|
|
14
|
+
function centralOperatorRoutes(basePath, sink, owners) {
|
|
15
|
+
if (sink.sinkDbProfile !== 'central') {
|
|
16
|
+
return { routes: [], provider: undefined, decisions: undefined };
|
|
17
|
+
}
|
|
18
|
+
const provider = operatorsFromPg(sink.pool);
|
|
19
|
+
const decisions = userDecisionsFromPg(sink.pool);
|
|
20
|
+
return {
|
|
21
|
+
routes: [
|
|
22
|
+
...operatorRoute(basePath, provider),
|
|
23
|
+
...decisionRoute(basePath, decisions, owners)
|
|
24
|
+
],
|
|
25
|
+
provider,
|
|
26
|
+
decisions
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves operator HTTP catalog for central or edge sink profile.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} basePath - plant API base path
|
|
34
|
+
* @param {object} sink - supervisor sink
|
|
35
|
+
* @param {object} env - process environment
|
|
36
|
+
* @param {object} [owners] - machineOwners registry for edge decision proxy
|
|
37
|
+
* @returns {object} routes, provider, decisions, sync
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* siteOperatorCatalog('/api/v1', sink, process.env, owners);
|
|
41
|
+
*/
|
|
42
|
+
export default function siteOperatorCatalog(basePath, sink, env, owners) {
|
|
43
|
+
if (sink.sinkDbProfile === 'central') {
|
|
44
|
+
return { ...centralOperatorRoutes(basePath, sink, owners), sync: undefined };
|
|
45
|
+
}
|
|
46
|
+
if (sink.sinkDbProfile === 'edge') {
|
|
47
|
+
const edge = edgeOperatorCatalog(basePath, env);
|
|
48
|
+
if (!sink.pool) {
|
|
49
|
+
return { ...edge, decisions: undefined };
|
|
50
|
+
}
|
|
51
|
+
const decisions = userDecisionsFromPg(sink.pool);
|
|
52
|
+
return {
|
|
53
|
+
...edge,
|
|
54
|
+
routes: [...edge.routes, ...decisionRoute(basePath, decisions, owners)],
|
|
55
|
+
decisions
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { routes: [], sync: undefined, provider: undefined, decisions: undefined };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Picks plant operatorCatalog override or the built-in site catalog.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} config - siteServer config, may include operatorCatalog factory
|
|
65
|
+
* @param {string} basePath - plant API base path
|
|
66
|
+
* @param {object} sink - supervisor sink
|
|
67
|
+
* @param {object} env - process environment
|
|
68
|
+
* @returns {object} routes, provider, decisions, sync
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* buildSiteOperatorCatalog({ operatorCatalog: plantCatalog }, '/api/v1', sink, env);
|
|
72
|
+
*/
|
|
73
|
+
export function buildSiteOperatorCatalog(config, basePath, sink, env) {
|
|
74
|
+
const factory = config && config.operatorCatalog
|
|
75
|
+
? config.operatorCatalog
|
|
76
|
+
: siteOperatorCatalog;
|
|
77
|
+
return factory(basePath, sink, env, config && config.owners);
|
|
78
|
+
}
|