@yarkivaev/scada 1.5.0 → 2.3.46

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.
Files changed (190) hide show
  1. package/README.md +196 -33
  2. package/db/migrations/C0001__central_drop_metrics.sql +1 -0
  3. package/db/migrations/C0002__central_revoke_delete.sql +12 -0
  4. package/db/migrations/C0003__central_operators.sql +7 -0
  5. package/db/migrations/C0004__central_operations_grants.sql +2 -0
  6. package/db/migrations/C0005__central_operators_grants.sql +1 -0
  7. package/db/migrations/C0006__central_operators_registration.sql +16 -0
  8. package/db/migrations/E0001__edge_metrics.sql +8 -0
  9. package/db/migrations/E0002__edge_retention_delete.sql +8 -0
  10. package/db/migrations/E0003__edge_operations_grants.sql +2 -0
  11. package/db/migrations/V0001__baseline.sql +25 -0
  12. package/db/migrations/V0002__sink_extension_tables.sql +6 -0
  13. package/db/migrations/V0003__operations.sql +10 -0
  14. package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
  15. package/db/migrations/V0005__user_decisions_operator.sql +2 -0
  16. package/index.js +75 -46
  17. package/package.json +28 -9
  18. package/src/application/bindSilentStreams.js +20 -0
  19. package/src/application/edgeOperatorCatalog.js +45 -0
  20. package/src/application/export/exportJob.js +118 -0
  21. package/src/application/export/exportQuery.js +32 -0
  22. package/src/application/export/exportSink.js +25 -0
  23. package/src/application/export/exportStream.js +33 -0
  24. package/src/application/machineInPlant.js +20 -0
  25. package/src/application/metricsPlant.js +35 -0
  26. package/src/application/plantApi.js +49 -0
  27. package/src/application/plantOperations.js +19 -0
  28. package/src/application/plantServer.js +107 -0
  29. package/src/application/shopWithTimeline.js +112 -0
  30. package/src/application/siteOperatorCatalog.js +79 -0
  31. package/src/application/siteServer.js +191 -0
  32. package/src/application/supervisorSink.js +97 -0
  33. package/src/application/timelineOperatorFromEnv.js +19 -0
  34. package/src/bin/supervisor-sink.js +28 -0
  35. package/src/{alert.js → domain/alerting/alert.js} +2 -2
  36. package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
  37. package/src/domain/alerting/ingest.js +18 -0
  38. package/src/domain/ingest/ingestCursor.js +26 -0
  39. package/src/domain/operation/operation.js +23 -0
  40. package/src/domain/operation/operations.js +81 -0
  41. package/src/domain/operator/operator.js +23 -0
  42. package/src/domain/plant/machine.js +32 -0
  43. package/src/domain/plant/plant.js +20 -0
  44. package/src/domain/plant/shop.js +24 -0
  45. package/src/domain/segment/dispatch.js +31 -0
  46. package/src/domain/segment/normalize.js +31 -0
  47. package/src/domain/segment/silenceBudget.js +16 -0
  48. package/src/{initialized.js → domain/shared/initialized.js} +3 -4
  49. package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
  50. package/src/domain/timeline/timeline.js +25 -0
  51. package/src/infrastructure/catalog/tag-catalog.json +27 -0
  52. package/src/infrastructure/catalog/tagCatalog.js +65 -0
  53. package/src/infrastructure/client/index.js +20 -0
  54. package/src/infrastructure/client/machineClient.js +159 -0
  55. package/src/infrastructure/client/machineOperationsClient.js +159 -0
  56. package/src/infrastructure/client/scadaClient.js +67 -0
  57. package/src/infrastructure/client/sseConnection.js +42 -0
  58. package/src/infrastructure/http/edge/edgeApi.js +54 -0
  59. package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
  60. package/src/infrastructure/http/edge/metricsSensor.js +15 -0
  61. package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
  62. package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
  63. package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
  64. package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
  65. package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
  66. package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
  67. package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
  68. package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
  69. package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
  70. package/src/infrastructure/http/edge/stateAccess.js +13 -0
  71. package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
  72. package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
  73. package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
  74. package/src/infrastructure/http/plant/json/operationJson.js +28 -0
  75. package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
  76. package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
  77. package/src/infrastructure/http/plant/operationAudit.js +56 -0
  78. package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
  79. package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
  80. package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
  81. package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
  82. package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
  83. package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
  84. package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
  85. package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
  86. package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
  87. package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
  88. package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
  89. package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
  90. package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
  91. package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
  92. package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
  93. package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
  94. package/src/infrastructure/http/plant/timelineOperator.js +87 -0
  95. package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
  96. package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
  97. package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
  98. package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
  99. package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
  100. package/src/infrastructure/ingest/cooldown.js +24 -0
  101. package/src/infrastructure/ingest/db/migrate.js +78 -0
  102. package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
  103. package/src/infrastructure/ingest/db/runRetention.js +58 -0
  104. package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
  105. package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
  106. package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
  107. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
  108. package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
  109. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
  110. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
  111. package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
  112. package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
  113. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
  114. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
  115. package/src/infrastructure/ingest/processingErrorLog.js +25 -0
  116. package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
  117. package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
  118. package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
  119. package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
  120. package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
  121. package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
  122. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
  123. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
  124. package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
  125. package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
  126. package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
  127. package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
  128. package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
  129. package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
  130. package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
  131. package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
  132. package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
  133. package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
  134. package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
  135. package/src/infrastructure/messaging/stomp/timeline.js +21 -0
  136. package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
  137. package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
  138. package/src/infrastructure/operators/centralOperators.js +64 -0
  139. package/src/infrastructure/operators/edgeOperators.js +39 -0
  140. package/src/infrastructure/operators/operatorById.js +33 -0
  141. package/src/infrastructure/operators/operatorExtras.js +41 -0
  142. package/src/infrastructure/operators/operators.js +32 -0
  143. package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
  144. package/src/infrastructure/operators/operatorsSync.js +57 -0
  145. package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
  146. package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
  147. package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
  148. package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
  149. package/src/infrastructure/persistence/memory/alerts.js +21 -0
  150. package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
  151. package/src/infrastructure/persistence/memory/metrics.js +69 -0
  152. package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
  153. package/src/infrastructure/persistence/memory/operations.js +87 -0
  154. package/src/infrastructure/persistence/memory/segments.js +83 -0
  155. package/src/infrastructure/persistence/memory/timeline.js +56 -0
  156. package/src/infrastructure/persistence/metricsSensor.js +112 -0
  157. package/src/infrastructure/persistence/pg/alerts.js +25 -0
  158. package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
  159. package/src/infrastructure/persistence/pg/metrics.js +73 -0
  160. package/src/infrastructure/persistence/pg/operations.js +95 -0
  161. package/src/infrastructure/persistence/pg/operators.js +118 -0
  162. package/src/infrastructure/persistence/pg/segments.js +48 -0
  163. package/src/infrastructure/persistence/pg/timeline.js +53 -0
  164. package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
  165. package/src/infrastructure/persistence/postgresPool.js +24 -0
  166. package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
  167. package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
  168. package/src/infrastructure/sync/operationCodec.js +75 -0
  169. package/src/infrastructure/sync/operationSyncIngest.js +82 -0
  170. package/src/infrastructure/sync/operationSyncSink.js +40 -0
  171. package/src/activeMelting.js +0 -54
  172. package/src/completedMelting.js +0 -42
  173. package/src/event.js +0 -24
  174. package/src/events.js +0 -51
  175. package/src/interval.js +0 -18
  176. package/src/machineChronology.js +0 -79
  177. package/src/meltingChronology.js +0 -37
  178. package/src/meltingMachine.js +0 -53
  179. package/src/meltingRuleEngine.js +0 -37
  180. package/src/meltingShop.js +0 -32
  181. package/src/meltings.js +0 -97
  182. package/src/monitoredMeltingMachine.js +0 -33
  183. package/src/plant.js +0 -23
  184. package/src/postgresSensor.js +0 -105
  185. package/src/requests.js +0 -44
  186. package/src/rule.js +0 -33
  187. package/src/rules.js +0 -23
  188. package/src/scyllaSensor.js +0 -54
  189. package/src/segments.js +0 -64
  190. package/src/sqliteSensor.js +0 -106
@@ -0,0 +1,42 @@
1
+ import operationJson from '../json/operationJson.js';
2
+ import { route, sseResponse } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Operations SSE route for plant-wide create, update, and delete events.
6
+ *
7
+ * @param {string} basePath - base URL path
8
+ * @param {object} plant - plant domain object
9
+ * @param {function} clock - time provider
10
+ * @returns {array} route objects
11
+ *
12
+ * @example
13
+ * operationStream('/api/v1', plant, clock);
14
+ */
15
+ export default function operationStream(basePath, plant, clock) {
16
+ return [
17
+ route('GET', `${basePath}/operations/stream`, (req, res) => {
18
+ const sse = sseResponse(res, clock);
19
+ sse.heartbeat();
20
+ if (!plant.operations) {
21
+ sse.close();
22
+ return;
23
+ }
24
+ const subscription = plant.operations.stream((event) => {
25
+ if (event.type === 'created' && event.operation) {
26
+ sse.emit('operation_created', operationJson(event.operation));
27
+ } else if (event.type === 'updated' && event.operation) {
28
+ sse.emit('operation_updated', operationJson(event.operation));
29
+ } else if (event.type === 'deleted' && event.operation) {
30
+ sse.emit('operation_deleted', operationJson(event.operation));
31
+ }
32
+ });
33
+ const heartbeat = setInterval(() => {
34
+ sse.heartbeat();
35
+ }, 30000);
36
+ req.on('close', () => {
37
+ clearInterval(heartbeat);
38
+ subscription.cancel();
39
+ });
40
+ })
41
+ ];
42
+ }
@@ -0,0 +1,97 @@
1
+ import machineInPlant from '../../../../application/machineInPlant.js';
2
+ import { route, sseResponse } from '@yarkivaev/simple-server';
3
+
4
+ function segmentPayload(segment) {
5
+ const start = segment.start_time || segment.startTime;
6
+ const end = segment.end_time || segment.endTime;
7
+ const data = {
8
+ name: segment.name,
9
+ start: start.toISOString(),
10
+ end: end.toISOString(),
11
+ duration: segment.duration
12
+ };
13
+ if (segment.options) {
14
+ data.options = segment.options;
15
+ }
16
+ if (segment.tags) {
17
+ data.tags = segment.tags;
18
+ }
19
+ if (segment.properties) {
20
+ data.properties = segment.properties;
21
+ }
22
+ return data;
23
+ }
24
+
25
+ /**
26
+ * Timeline SSE routes for segments and label requests.
27
+ *
28
+ * @param {string} basePath - base URL path
29
+ * @param {object} plant - plant domain object
30
+ * @param {function} clock - time provider
31
+ * @returns {array} route objects
32
+ *
33
+ * @example
34
+ * timelineStream('/api/v1', plant, clock);
35
+ */
36
+ export default function timelineStream(basePath, plant, clock) {
37
+ return [
38
+ route('GET', `${basePath}/machines/:machineId/segments/stream`, (req, res, params) => {
39
+ const sse = sseResponse(res, clock);
40
+ sse.heartbeat();
41
+ const result = machineInPlant(plant, params.machineId);
42
+ if (!result) {
43
+ sse.close();
44
+ return;
45
+ }
46
+ const subscription = result.machine.timeline.stream((event) => {
47
+ if (event.type === 'created' && event.segment) {
48
+ sse.emit('segment_created', segmentPayload(event.segment));
49
+ } else if (event.type === 'resolved' && event.segment) {
50
+ sse.emit('segment_resolved', segmentPayload(event.segment));
51
+ }
52
+ });
53
+ const heartbeat = setInterval(() => {
54
+ sse.heartbeat();
55
+ }, 30000);
56
+ req.on('close', () => {
57
+ clearInterval(heartbeat);
58
+ subscription.cancel();
59
+ });
60
+ }),
61
+ route('GET', `${basePath}/machines/:machineId/requests/stream`, (req, res, params) => {
62
+ const sse = sseResponse(res, clock);
63
+ sse.heartbeat();
64
+ const result = machineInPlant(plant, params.machineId);
65
+ if (!result) {
66
+ sse.close();
67
+ return;
68
+ }
69
+ const subscription = result.machine.timeline.stream((event) => {
70
+ if (event.type === 'created' && event.request) {
71
+ const reqItem = event.request;
72
+ const start = reqItem.start_time || reqItem.startTime;
73
+ const end = reqItem.end_time || reqItem.endTime;
74
+ sse.emit('request_created', {
75
+ id: reqItem.id,
76
+ segment: {
77
+ name: reqItem.name,
78
+ start: start.toISOString(),
79
+ end: end.toISOString(),
80
+ duration: reqItem.duration
81
+ },
82
+ options: reqItem.options
83
+ });
84
+ } else if (event.type === 'resolved' && event.request) {
85
+ sse.emit('request_resolved', { id: event.request.id });
86
+ }
87
+ });
88
+ const heartbeat = setInterval(() => {
89
+ sse.heartbeat();
90
+ }, 30000);
91
+ req.on('close', () => {
92
+ clearInterval(heartbeat);
93
+ subscription.cancel();
94
+ });
95
+ })
96
+ ];
97
+ }
@@ -0,0 +1,87 @@
1
+ import operatorById from '../../operators/operatorById.js';
2
+ import { errorResponse } from '@yarkivaev/simple-server';
3
+
4
+ function routeError(code, message, status) {
5
+ const err = new Error(message);
6
+ err.routeCode = code;
7
+ err.routeStatus = status;
8
+ return err;
9
+ }
10
+
11
+ /**
12
+ * Resolves anonymous display name from an optional client marker.
13
+ *
14
+ * @param {object} body - parsed JSON body with optional client
15
+ * @param {object} cfg - timelineOperator options with anonymousUsers and defaultUser
16
+ * @returns {string} display name for the decision audit row
17
+ */
18
+ function anonymousName(body, cfg) {
19
+ const key = body && body.client;
20
+ const map = cfg.anonymousUsers || {};
21
+ if (typeof key === 'string' && Object.hasOwn(map, key)) {
22
+ return map[key];
23
+ }
24
+ return cfg.defaultUser || 'hmi-kiosk';
25
+ }
26
+
27
+ /**
28
+ * Resolves operator audit context for timeline write routes.
29
+ *
30
+ * @param {object} options - provider, requireOperator, defaultUser, anonymousUsers
31
+ * @returns {object} gate with resolve(body) returning audit context
32
+ *
33
+ * @example
34
+ * const gate = timelineOperator({
35
+ * provider, requireOperator: true, defaultUser: 'hmi-kiosk',
36
+ * anonymousUsers: { hmi: 'Anonymous HMI user' }
37
+ * });
38
+ * const audit = await gate.resolve({ operatorId: 2 });
39
+ */
40
+ export default function timelineOperator(options) {
41
+ const cfg = options || {};
42
+ const lookup = cfg.provider ? operatorById(cfg.provider) : undefined;
43
+ return {
44
+ /**
45
+ * Builds audit context from request body operatorId field.
46
+ *
47
+ * @param {object} body - parsed JSON body with optional operatorId
48
+ * @returns {Promise<object>} audit with id, displayName, decidedAt
49
+ */
50
+ async resolve(body) {
51
+ const { operatorId } = body;
52
+ const decidedAt = new Date();
53
+ if (cfg.requireOperator && (operatorId === undefined || operatorId === null)) {
54
+ throw routeError('FORBIDDEN', 'operator required', 403);
55
+ }
56
+ if (operatorId !== undefined && operatorId !== null) {
57
+ if (!lookup) {
58
+ throw routeError('SERVICE_UNAVAILABLE', 'operators catalog unavailable', 503);
59
+ }
60
+ const row = await lookup.resolve(operatorId);
61
+ if (!row) {
62
+ throw routeError('BAD_REQUEST', `unknown operator id ${operatorId}`, 400);
63
+ }
64
+ return { id: row.id, displayName: row.displayName, decidedAt };
65
+ }
66
+ return {
67
+ id: undefined,
68
+ displayName: anonymousName(body, cfg),
69
+ decidedAt
70
+ };
71
+ },
72
+ /**
73
+ * Sends a route error response when resolve threw a route error.
74
+ *
75
+ * @param {object} res - HTTP response
76
+ * @param {Error} err - error from resolve()
77
+ * @returns {boolean} true when a response was sent
78
+ */
79
+ sendError(res, err) {
80
+ if (err.routeCode && err.routeStatus) {
81
+ errorResponse(err.routeCode, err.message, err.routeStatus).send(res);
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ };
87
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ batch,
3
+ circuit,
4
+ clickhouseSink,
5
+ clock,
6
+ lokiSource,
7
+ timedBatch
8
+ } from '@yarkivaev/source-to-sink';
9
+ import activityCodec from './activityTransformer.js';
10
+
11
+ /**
12
+ * Pipeline for streaming Loki HTTP access logs to ClickHouse.
13
+ *
14
+ * Polls Loki for log entries matching the query and batches them
15
+ * before inserting into ClickHouse `activity.http_access` table.
16
+ * Uses circuit breaker for failure isolation.
17
+ *
18
+ * @example
19
+ * const pipeline = activityTracking(
20
+ * 'http://localhost:3100',
21
+ * 'http://localhost:8123',
22
+ * '{app="traefik"}',
23
+ * { poll: 10, size: 100, interval: 5, threshold: 5, timeout: 60 }
24
+ * );
25
+ * pipeline.start();
26
+ * // ... later
27
+ * pipeline.stop();
28
+ *
29
+ * @param {string} loki - Loki base URL
30
+ * @param {string} clickhouse - ClickHouse URL
31
+ * @param {string} query - LogQL query string
32
+ * @param {object} config - Pipeline configuration
33
+ * @param {number} config.poll - Loki polling interval in seconds
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
+ * @returns {object} Pipeline with start() and stop() methods
39
+ */
40
+ export default function activityTracking(loki, clickhouse, query, config) {
41
+ if (typeof loki !== 'string' || loki.length === 0) {
42
+ throw new Error('Loki URL must be a non-empty string');
43
+ }
44
+ if (typeof clickhouse !== 'string' || clickhouse.length === 0) {
45
+ throw new Error('ClickHouse URL must be a non-empty string');
46
+ }
47
+ if (typeof query !== 'string' || query.length === 0) {
48
+ throw new Error('Query must be a non-empty string');
49
+ }
50
+ if (!config || typeof config !== 'object') {
51
+ throw new Error('Config must be an object');
52
+ }
53
+ const clk = clock();
54
+ const breaker = circuit(config.threshold, config.timeout, clk);
55
+ const sink = clickhouseSink(clickhouse, 'activity.http_access');
56
+ const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
57
+ const transformer = activityCodec(collector);
58
+ const source = lokiSource(loki, query, config.poll, transformer, clk);
59
+ return {
60
+ /**
61
+ * Starts the pipeline.
62
+ */
63
+ start() {
64
+ source.start();
65
+ },
66
+ /**
67
+ * Stops the pipeline.
68
+ */
69
+ stop() {
70
+ source.stop();
71
+ collector.stop();
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Transformer for converting Traefik JSON log entries to activity records.
3
+ *
4
+ * Parses Traefik access log format and extracts user activity fields.
5
+ * Skips entries that cannot be parsed or lack required fields.
6
+ *
7
+ * @example
8
+ * const transformer = activityTransformer(collector);
9
+ * transformer.accept({ ts: 123, line: '{"StartUTC":"...","RequestMethod":"GET",...}' });
10
+ *
11
+ * @param {object} collector - Collector with accept() method
12
+ * @returns {object} Transformer with accept() method
13
+ */
14
+ export default function activityCodec(collector) {
15
+ if (!collector || typeof collector.accept !== 'function') {
16
+ throw new Error('Collector must have an accept() method');
17
+ }
18
+ return {
19
+ /**
20
+ * Accepts Loki log entry and transforms to activity record.
21
+ *
22
+ * @param {object} entry - Log entry with ts and line
23
+ */
24
+ accept(entry) {
25
+ try {
26
+ const log = JSON.parse(entry.line);
27
+ if (!log.StartUTC || !log.RequestMethod || !log.RequestPath) {
28
+ return;
29
+ }
30
+ collector.accept({
31
+ ts: new Date(log.StartUTC).getTime(),
32
+ user: log['request_Remote-User'] || '',
33
+ email: log['request_Remote-Email'] || '',
34
+ method: log.RequestMethod || '',
35
+ path: log.RequestPath || '',
36
+ status: parseInt(log.DownstreamStatus, 10) || 0,
37
+ duration: parseFloat(log.Duration) / 1e9 || 0,
38
+ ip: log.ClientHost || ''
39
+ });
40
+ } catch {
41
+ // Skip malformed log entries
42
+ }
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,56 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ /**
4
+ * Codec for converting raw STOMP alert messages to PostgreSQL-ready records.
5
+ *
6
+ * Parses JSON payloads and extracts name, machine, severity, status,
7
+ * and timestamps. Translates rule names to human-readable messages
8
+ * using the provided translation map. Converts epoch timestamps to
9
+ * ISO strings for PostgreSQL TIMESTAMPTZ columns.
10
+ *
11
+ * @example
12
+ * const codec = alertCodec(collector, { low_cosphi: 'Switch off compensation' });
13
+ * codec.accept({ destination: '/exchange/scada.alerts', payload: '{"name":"low_cosphi","machine":"m2","severity":"warning","status":"pending","start":1700000000}' });
14
+ *
15
+ * @param {object} collector - Collector with accept() method
16
+ * @param {object} translations - Map of rule names to human-readable messages
17
+ * @returns {object} Codec with accept() method
18
+ */
19
+ export default function alertCodec(collector, translations) {
20
+ if (!collector || typeof collector.accept !== 'function') {
21
+ throw new Error('Collector must have an accept() method');
22
+ }
23
+ return {
24
+ /**
25
+ * Accepts a raw STOMP message and forwards the decoded record to the collector.
26
+ *
27
+ * @param {object} raw - Raw message with destination and payload
28
+ */
29
+ async accept(raw) {
30
+ try {
31
+ const parsed = JSON.parse(raw.payload);
32
+ if (typeof parsed.name !== 'string') {
33
+ throw new Error('Alert missing name field');
34
+ }
35
+ if (typeof parsed.machine !== 'string') {
36
+ throw new Error('Alert missing machine field');
37
+ }
38
+ const timestamp = new Date(parsed.start);
39
+ if (isNaN(timestamp.getTime())) {
40
+ throw new RangeError(`Invalid epoch timestamp: ${parsed.start}`);
41
+ }
42
+ await collector.accept({
43
+ name: parsed.name,
44
+ message: translations[parsed.name] || parsed.name,
45
+ machine: parsed.machine,
46
+ severity: parsed.severity,
47
+ status: parsed.status,
48
+ timestamp: timestamp.toISOString()
49
+ });
50
+ } catch (error) {
51
+ processingErrorLog('alert_codec', error, { destination: raw.destination, payload: raw.payload });
52
+ throw error;
53
+ }
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,30 @@
1
+ import segmentNormalize from '../../../domain/segment/normalize.js';
2
+ import processingErrorLog from '../processingErrorLog.js';
3
+
4
+ /**
5
+ * Codec for converting raw STOMP segment messages to PostgreSQL-ready records.
6
+ *
7
+ * @param {object} collector - Collector with accept() method
8
+ * @returns {object} Codec with accept() method
9
+ *
10
+ * @example
11
+ * const codec = segmentCodec(collector);
12
+ * codec.accept({ destination: '/exchange/scada.segments', payload: '{"machine":"m2","name":"on","start":1700000000,"end":1700003600,"duration":3600}' });
13
+ */
14
+ export default function segmentCodec(collector) {
15
+ if (!collector || typeof collector.accept !== 'function') {
16
+ throw new Error('Collector must have an accept() method');
17
+ }
18
+ return {
19
+ async accept(raw) {
20
+ try {
21
+ const parsed = JSON.parse(raw.payload);
22
+ const record = segmentNormalize(parsed);
23
+ await collector.accept(record);
24
+ } catch (error) {
25
+ processingErrorLog('segment_codec', error, { destination: raw.destination, payload: raw.payload });
26
+ throw error;
27
+ }
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,78 @@
1
+ import processingErrorLog from '../processingErrorLog.js';
2
+
3
+ function parseStartTime(parsed) {
4
+ const startTime = new Date(parsed.start * 1000);
5
+ if (isNaN(startTime.getTime())) {
6
+ throw new RangeError(`Invalid epoch timestamp: ${parsed.start}`);
7
+ }
8
+ return startTime;
9
+ }
10
+
11
+ function parseDecidedAt(parsed) {
12
+ if (parsed.decided_at === undefined || parsed.decided_at === null) {
13
+ return new Date().toISOString();
14
+ }
15
+ const decided = new Date(parsed.decided_at * 1000);
16
+ if (isNaN(decided.getTime())) {
17
+ throw new RangeError(`Invalid decided_at timestamp: ${parsed.decided_at}`);
18
+ }
19
+ return decided.toISOString();
20
+ }
21
+
22
+ function decisionRecord(raw, parsed, startTime, decidedAt) {
23
+ const record = {
24
+ machine: parsed.machine,
25
+ startTime: startTime.toISOString(),
26
+ username: parsed.user,
27
+ payload: raw.payload,
28
+ decidedAt
29
+ };
30
+ if (parsed.operator_id !== undefined && parsed.operator_id !== null) {
31
+ record.operatorId = Number(parsed.operator_id);
32
+ }
33
+ return record;
34
+ }
35
+
36
+ /**
37
+ * Codec for converting raw STOMP user decision messages to sink-ready records.
38
+ *
39
+ * Parses JSON payloads and extracts machine, start timestamp, user, operator_id,
40
+ * decided_at, and the full raw payload for audit storage.
41
+ *
42
+ * @example
43
+ * const codec = userDecisionCodec(collector);
44
+ * codec.accept({ destination: '/exchange/scada.user_decisions',
45
+ * payload: '{"machine":"m2","start":1700000000,"user":"op1","operator_id":2}' });
46
+ *
47
+ * @param {object} collector - Collector with accept() method
48
+ * @returns {object} Codec with accept() method
49
+ */
50
+ export default function userDecisionCodec(collector) {
51
+ if (!collector || typeof collector.accept !== 'function') {
52
+ throw new Error('Collector must have an accept() method');
53
+ }
54
+ return {
55
+ /**
56
+ * Accepts a raw STOMP message and forwards the decoded record to the collector.
57
+ *
58
+ * @param {object} raw - Raw message with destination and payload
59
+ */
60
+ async accept(raw) {
61
+ try {
62
+ const parsed = JSON.parse(raw.payload);
63
+ if (typeof parsed.machine !== 'string') {
64
+ throw new Error('Decision missing machine field');
65
+ }
66
+ if (typeof parsed.user !== 'string') {
67
+ throw new Error('Decision missing user field');
68
+ }
69
+ const startTime = parseStartTime(parsed);
70
+ const decidedAt = parseDecidedAt(parsed);
71
+ await collector.accept(decisionRecord(raw, parsed, startTime, decidedAt));
72
+ } catch (error) {
73
+ processingErrorLog('decision_codec', error, { destination: raw.destination, payload: raw.payload });
74
+ throw error;
75
+ }
76
+ }
77
+ };
78
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Decorator that rate-limits an issue callback by message.
3
+ * Blocks duplicate messages within the cooldown interval.
4
+ *
5
+ * @param {function} issue - callback to wrap (message, timestamp)
6
+ * @param {number} interval - cooldown interval in milliseconds
7
+ * @returns {function} wrapped callback that enforces cooldown
8
+ *
9
+ * @example
10
+ * const limited = cooldown(console.log, 60000);
11
+ * limited('alert', new Date()); // fires
12
+ * limited('alert', new Date()); // blocked (within 60s)
13
+ */
14
+ export default function cooldown(issue, interval) {
15
+ const history = {};
16
+ return function limited(message, timestamp) {
17
+ const now = timestamp.getTime();
18
+ const last = history[message] || 0;
19
+ if (now - last >= interval) {
20
+ history[message] = now;
21
+ issue(message, timestamp);
22
+ }
23
+ };
24
+ }
@@ -0,0 +1,78 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { migrationApplies, migrationSinkRunnable } from './migrationProfile.js';
4
+ /* eslint-disable no-await-in-loop */
5
+
6
+ function migrationSelected(name, profile, privileged) {
7
+ if (!name.endsWith('.sql') || !migrationApplies(name, profile)) {
8
+ return false;
9
+ }
10
+ const sinkRunnable = migrationSinkRunnable(name);
11
+ return privileged ? !sinkRunnable : sinkRunnable;
12
+ }
13
+
14
+ async function ensureMigrationsTable(pool) {
15
+ await pool.query(`
16
+ CREATE TABLE IF NOT EXISTS schema_migrations (
17
+ version TEXT PRIMARY KEY,
18
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
19
+ )
20
+ `);
21
+ }
22
+
23
+ async function applyMigrationFiles(pool, migrationsDir, profile, privileged) {
24
+ const files = (await fs.readdir(migrationsDir))
25
+ .filter((name) => {
26
+ return migrationSelected(name, profile, privileged);
27
+ })
28
+ .sort((left, right) => {
29
+ return left.localeCompare(right);
30
+ });
31
+ for (const file of files) {
32
+ const { rowCount } = await pool.query(
33
+ 'SELECT 1 FROM schema_migrations WHERE version = $1',
34
+ [file]
35
+ );
36
+ if (rowCount === 0) {
37
+ const sql = await fs.readFile(path.join(migrationsDir, file), 'utf8');
38
+ await pool.query('BEGIN');
39
+ try {
40
+ await pool.query(sql);
41
+ await pool.query(
42
+ 'INSERT INTO schema_migrations (version) VALUES ($1)',
43
+ [file]
44
+ );
45
+ await pool.query('COMMIT');
46
+ } catch (error) {
47
+ await pool.query('ROLLBACK');
48
+ throw error;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Applies SQL migrations runnable as supervisor_sink.
56
+ *
57
+ * @param {object} pool - pg.Pool instance.
58
+ * @param {string} migrationsDir - absolute path to migration files.
59
+ * @param {string} [profile] - central or edge; filters E/C prefixed files
60
+ * @returns {Promise<void>}
61
+ */
62
+ export default async function migrate(pool, migrationsDir, profile = 'edge') {
63
+ await ensureMigrationsTable(pool);
64
+ await applyMigrationFiles(pool, migrationsDir, profile, false);
65
+ }
66
+
67
+ /**
68
+ * Applies privilege migrations that require Postgres superuser (scada).
69
+ *
70
+ * @param {object} pool - pg.Pool connected as scada or other superuser.
71
+ * @param {string} migrationsDir - absolute path to migration files.
72
+ * @param {string} [profile] - central or edge; filters E/C prefixed files
73
+ * @returns {Promise<void>}
74
+ */
75
+ export async function migratePrivileged(pool, migrationsDir, profile = 'central') {
76
+ await ensureMigrationsTable(pool);
77
+ await applyMigrationFiles(pool, migrationsDir, profile, true);
78
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Returns whether a migration file applies to the given database profile.
3
+ *
4
+ * @param {string} fileName - migration file name
5
+ * @param {string} profile - sink database profile: central or edge
6
+ * @returns {boolean}
7
+ */
8
+ export function migrationApplies(fileName, profile) {
9
+ if (fileName.startsWith('E')) {
10
+ return profile === 'edge';
11
+ }
12
+ if (fileName.startsWith('C')) {
13
+ return profile === 'central';
14
+ }
15
+ return true;
16
+ }
17
+
18
+ const privilegedOnly = new Set([
19
+ 'C0002__central_revoke_delete.sql',
20
+ 'C0004__central_operations_grants.sql',
21
+ 'C0005__central_operators_grants.sql',
22
+ 'C0006__central_operators_registration.sql',
23
+ 'E0002__edge_retention_delete.sql',
24
+ 'E0003__edge_operations_grants.sql'
25
+ ]);
26
+
27
+ /**
28
+ * Privilege migrations run as scada only, not supervisor_sink.
29
+ *
30
+ * @param {string} fileName - migration file name
31
+ * @returns {boolean}
32
+ */
33
+ export function migrationSinkRunnable(fileName) {
34
+ return !privilegedOnly.has(fileName);
35
+ }