@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.
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 +78 -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,31 @@
1
+ /**
2
+ * Normalizes a parsed segment message into a persistence-ready record.
3
+ *
4
+ * @param {object} parsed - decoded STOMP JSON payload
5
+ * @returns {object} normalized segment record
6
+ *
7
+ * @example
8
+ * const row = segmentNormalize({ machine: 'm1', name: 'on', start: 1, end: 2, duration: 1 });
9
+ */
10
+ export default function segmentNormalize(parsed) {
11
+ if (typeof parsed.machine !== 'string') {
12
+ throw new Error('Segment missing machine field');
13
+ }
14
+ if (typeof parsed.name !== 'string') {
15
+ throw new Error('Segment missing name field');
16
+ }
17
+ const hasOpts = Array.isArray(parsed.options) && parsed.options.length > 0;
18
+ const resolved = typeof parsed.resolved === 'boolean' ? parsed.resolved : !hasOpts;
19
+ return {
20
+ type: parsed.type,
21
+ machine: parsed.machine,
22
+ name: parsed.name,
23
+ start_time: new Date(parsed.start).toISOString(),
24
+ end_time: new Date(parsed.end).toISOString(),
25
+ duration: parsed.duration,
26
+ options: parsed.options ? JSON.stringify(parsed.options) : null,
27
+ tags: parsed.tags ? JSON.stringify(parsed.tags) : null,
28
+ properties: parsed.properties ? JSON.stringify(parsed.properties) : null,
29
+ resolved
30
+ };
31
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Silence budget for persistence-side open segment close.
3
+ * Mirrors supervisor Segmentation: wall-clock idle of machine.window * 2.
4
+ *
5
+ * @param {number} window - supervisor machine window in seconds
6
+ * @returns {number} silence budget in seconds
7
+ *
8
+ * @example
9
+ * silenceBudget(15);
10
+ */
11
+ export default function silenceBudget(window) {
12
+ if (typeof window !== 'number' || !(window > 0)) {
13
+ throw new Error(`Window must be a positive number: ${window}`);
14
+ }
15
+ return window * 2;
16
+ }
@@ -7,10 +7,9 @@
7
7
  * @returns {object} wrapper with init() and get() methods
8
8
  *
9
9
  * @example
10
- * const machines = initialized({ icht1: machine }, Object.values);
11
- * machines.init(); // initializes all items once
12
- * machines.get().icht1; // access by key
13
- * Object.values(machines.get()); // iterate
10
+ * const machines = initialized({ m1: machine }, Object.values);
11
+ * machines.init();
12
+ * machines.get().m1;
14
13
  */
15
14
  export default function initialized(collection, toList) {
16
15
  let done = false;
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * Simple publish-subscribe mechanism for event distribution.
3
3
  * Provides emit() to publish and stream() to subscribe.
4
- * Abstraction point for future message broker integration.
5
4
  *
6
5
  * @returns {object} bus with emit() and stream() methods
7
6
  *
8
7
  * @example
9
8
  * const bus = pubsub();
10
- * const sub = bus.stream((e) => console.log(e));
11
- * bus.emit({ type: 'created', data: {...} });
9
+ * const sub = bus.stream((evt) => console.log(evt));
10
+ * bus.emit({ type: 'created', data: {} });
12
11
  * sub.cancel();
13
12
  */
14
13
  export default function pubsub() {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Read-only timeline port wired to a pubsub event bus.
3
+ *
4
+ * @param {object} read - read port with list, rowAt, pending
5
+ * @param {object} bus - pubsub instance with stream method
6
+ * @returns {object} timeline with list, rowAt, pending, stream
7
+ *
8
+ * @example
9
+ * const tl = timeline(pgTimeline(pool, 'm1'), bus);
10
+ * await tl.list({ from: '2024-01-01' });
11
+ */
12
+ export default function timeline(read, bus) {
13
+ return {
14
+ list(range) {
15
+ return read.list(range);
16
+ },
17
+ rowAt(start) {
18
+ return read.rowAt(start);
19
+ },
20
+ pending() {
21
+ return read.pending();
22
+ },
23
+ stream: bus.stream
24
+ };
25
+ }
@@ -0,0 +1,27 @@
1
+ [
2
+ {
3
+ "id": "on",
4
+ "label": "On",
5
+ "parent": null
6
+ },
7
+ {
8
+ "id": "off",
9
+ "label": "Off",
10
+ "parent": null
11
+ },
12
+ {
13
+ "id": "heating",
14
+ "label": "Heating",
15
+ "parent": "on"
16
+ },
17
+ {
18
+ "id": "idle",
19
+ "label": "Idle",
20
+ "parent": "off"
21
+ },
22
+ {
23
+ "id": "maintenance",
24
+ "label": "Maintenance",
25
+ "parent": "off"
26
+ }
27
+ ]
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ /**
6
+ * Loads hierarchical tag catalog entries from disk or embedded JSON.
7
+ *
8
+ * Prefers TAG_CATALOG_PATH when set so deploy ConfigMaps override the
9
+ * package copy without rebuilding the image.
10
+ *
11
+ * @example
12
+ * const catalog = tagCatalog();
13
+ * catalog.labels().heating;
14
+ *
15
+ * @param {object} [env] - environment map, defaults to process.env
16
+ * @returns {object} catalog with entries(), labels(), roots(parent)
17
+ */
18
+ export default function tagCatalog(env) {
19
+ const source = env || process.env;
20
+ const path = source.TAG_CATALOG_PATH
21
+ || join(dirname(fileURLToPath(import.meta.url)), 'tag-catalog.json');
22
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
23
+ if (!Array.isArray(raw)) {
24
+ throw new Error('tag catalog must be a JSON array');
25
+ }
26
+ const byId = Object.fromEntries(raw.map((row) => {
27
+ return [row.id, row];
28
+ }));
29
+ return Object.freeze({
30
+ /**
31
+ * @returns {Array<object>} catalog rows
32
+ */
33
+ entries() {
34
+ return raw.slice();
35
+ },
36
+ /**
37
+ * @returns {object} id to label map
38
+ */
39
+ labels() {
40
+ const map = {};
41
+ raw.forEach((row) => {
42
+ map[row.id] = row.label;
43
+ });
44
+ return map;
45
+ },
46
+ /**
47
+ * @param {string} parent - parent id or segment name
48
+ * @returns {Array<string>} child ids
49
+ */
50
+ children(parent) {
51
+ return raw.filter((row) => {
52
+ return row.parent === parent;
53
+ }).map((row) => {
54
+ return row.id;
55
+ });
56
+ },
57
+ /**
58
+ * @param {string} id - tag id
59
+ * @returns {object|undefined} catalog row
60
+ */
61
+ get(id) {
62
+ return byId[id];
63
+ }
64
+ });
65
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * SCADA Server JS Client.
3
+ * Provides typed methods for all API endpoints.
4
+ *
5
+ * Export ports (Query/Stream/Sink/Job) are also re-exported for callers that
6
+ * depend on `@yarkivaev/scada/client` only; destination adapters stay in plant packages.
7
+ *
8
+ * @example
9
+ * import { scadaClient, exportQuery, exportJob } from '@yarkivaev/scada/client';
10
+ * const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource);
11
+ * const query = exportQuery(client);
12
+ */
13
+
14
+ export { default as machineClient } from './machineClient.js';
15
+ export { default as scadaClient } from './scadaClient.js';
16
+ export { default as sseConnection } from './sseConnection.js';
17
+ export { default as exportQuery } from '../../application/export/exportQuery.js';
18
+ export { default as exportStream } from '../../application/export/exportStream.js';
19
+ export { default as exportSink } from '../../application/export/exportSink.js';
20
+ export { default as exportJob } from '../../application/export/exportJob.js';
@@ -0,0 +1,159 @@
1
+ import sseConnection from './sseConnection.js';
2
+ import machineOperationsClient from './machineOperationsClient.js';
3
+
4
+ /**
5
+ * Builds a JSON request payload with method, headers, and body.
6
+ *
7
+ * @param {string} method - HTTP method
8
+ * @param {object} data - request body data
9
+ * @returns {object} fetch options with method, headers, and stringified body
10
+ */
11
+ function payload(method, data) {
12
+ return {
13
+ method,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ body: JSON.stringify(data)
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Builds a from/to query suffix for range GETs.
21
+ *
22
+ * @param {object} [options] - optional from/to timestamps
23
+ * @returns {string} empty string or ?from=&to= suffix
24
+ */
25
+ function rangeQuery(options) {
26
+ const params = new URLSearchParams();
27
+ if (options && options.from) {
28
+ params.set('from', options.from);
29
+ }
30
+ if (options && options.to) {
31
+ params.set('to', options.to);
32
+ }
33
+ const qs = params.toString();
34
+ return qs ? `?${qs}` : '';
35
+ }
36
+
37
+ /**
38
+ * Client for single machine endpoints.
39
+ * Returns object with methods for machine operations.
40
+ *
41
+ * @param {string} baseUrl - API base URL
42
+ * @param {string} machineId - machine identifier
43
+ * @param {function} fetcher - fetch function
44
+ * @param {function} eventSource - EventSource constructor
45
+ * @param {object} [logger] - optional logger with error(tag, detail)
46
+ * @returns {object} client with info, measurements, alerts, segments, cycles, operations methods
47
+ *
48
+ * @example
49
+ * const machine = machineClient(baseUrl, 'm1', fetch, EventSource, logger);
50
+ * const info = await machine.info();
51
+ * await machine.createOperation({ kind: 'bath', payload: { action: 'load' } });
52
+ * await machine.updateOperation(key, { payload: { action: 'load' } });
53
+ * await machine.deleteOperation(key);
54
+ */
55
+ export default function machineClient(baseUrl, machineId, fetcher, eventSource, logger) {
56
+ const url = `${baseUrl}/machines/${machineId}`;
57
+ async function request(path, options) {
58
+ const fullPath = `${url}${path}`;
59
+ let response;
60
+ try {
61
+ response = await fetcher(fullPath, options);
62
+ } catch (cause) {
63
+ if (logger && typeof logger.error === 'function') {
64
+ logger.error('api.network', { path: fullPath, cause });
65
+ }
66
+ throw cause;
67
+ }
68
+ const result = await response.json();
69
+ if (!response.ok) {
70
+ if (logger && typeof logger.error === 'function') {
71
+ logger.error('api', { path: fullPath, body: result });
72
+ }
73
+ throw result;
74
+ }
75
+ return result;
76
+ }
77
+ return {
78
+ info() {
79
+ return request('');
80
+ },
81
+ measurements(options) {
82
+ const params = new URLSearchParams();
83
+ if (options && options.keys) {
84
+ params.set('keys', options.keys.join(','));
85
+ }
86
+ if (options && options.from) {
87
+ params.set('from', options.from);
88
+ }
89
+ if (options && options.to) {
90
+ params.set('to', options.to);
91
+ }
92
+ if (options && options.step) {
93
+ params.set('step', String(options.step));
94
+ }
95
+ const qs = params.toString();
96
+ return request(`/measurements${qs ? `?${qs}` : ''}`);
97
+ },
98
+ measurementStream(options) {
99
+ const params = new URLSearchParams();
100
+ if (options && options.keys) {
101
+ params.set('keys', options.keys.join(','));
102
+ }
103
+ if (options && options.since) {
104
+ params.set('since', options.since);
105
+ }
106
+ if (options && options.step) {
107
+ params.set('step', String(options.step));
108
+ }
109
+ const qs = params.toString();
110
+ return sseConnection(
111
+ `${url}/measurements/stream${qs ? `?${qs}` : ''}`,
112
+ eventSource,
113
+ logger
114
+ );
115
+ },
116
+ alerts(options) {
117
+ const params = new URLSearchParams();
118
+ if (options && options.page) {
119
+ params.set('page', String(options.page));
120
+ }
121
+ if (options && options.size) {
122
+ params.set('size', String(options.size));
123
+ }
124
+ if (options && Object.hasOwn(options, 'acknowledged')) {
125
+ params.set('acknowledged', String(options.acknowledged));
126
+ }
127
+ const qs = params.toString();
128
+ return request(`/alerts${qs ? `?${qs}` : ''}`);
129
+ },
130
+ alertStream() {
131
+ return sseConnection(`${url}/alerts/stream`, eventSource, logger);
132
+ },
133
+ acknowledge(alertId) {
134
+ return request(`/alerts/${alertId}`, payload('PATCH', { acknowledged: true }));
135
+ },
136
+ segments(options) {
137
+ return request(`/segments${rangeQuery(options)}`);
138
+ },
139
+ cycles(options) {
140
+ return request(`/cycles${rangeQuery(options)}`);
141
+ },
142
+ retag(data) {
143
+ return request('/segments', payload('PATCH', data));
144
+ },
145
+ segmentStream() {
146
+ return sseConnection(`${url}/segments/stream`, eventSource, logger);
147
+ },
148
+ requests() {
149
+ return request('/requests');
150
+ },
151
+ requestStream() {
152
+ return sseConnection(`${url}/requests/stream`, eventSource, logger);
153
+ },
154
+ respond(requestId, data) {
155
+ return request(`/requests/${requestId}/respond`, payload('POST', data));
156
+ },
157
+ ...machineOperationsClient(baseUrl, request, eventSource, logger)
158
+ };
159
+ }
@@ -0,0 +1,159 @@
1
+ import sseConnection from './sseConnection.js';
2
+
3
+ /**
4
+ * Builds a JSON request payload with method, headers, and body.
5
+ *
6
+ * @param {string} method - HTTP method
7
+ * @param {object} data - request body data
8
+ * @returns {object} fetch options with method, headers, and stringified body
9
+ */
10
+ function payload(method, data) {
11
+ return {
12
+ method,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify(data)
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Copies optional audit fields onto an API body.
20
+ *
21
+ * @param {object} data - mutable body
22
+ * @param {object} fields - create/update/delete fields
23
+ */
24
+ function attachAudit(data, fields) {
25
+ if (fields.operatorId !== undefined) {
26
+ data.operatorId = fields.operatorId;
27
+ }
28
+ if (fields.client !== undefined) {
29
+ data.client = fields.client;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Maps camelCase createOperation fields to snake_case API body.
35
+ *
36
+ * @param {object} fields - kind, payload, optional occurredAt, key, operatorId, client
37
+ * @returns {object} POST body for /operations
38
+ */
39
+ function createBody(fields) {
40
+ const data = {
41
+ kind: fields.kind,
42
+ payload: fields.payload
43
+ };
44
+ if (fields.occurredAt !== undefined) {
45
+ data.occurred_at = fields.occurredAt;
46
+ }
47
+ if (fields.key !== undefined) {
48
+ data.key = fields.key;
49
+ }
50
+ attachAudit(data, fields);
51
+ return data;
52
+ }
53
+
54
+ /**
55
+ * Maps camelCase updateOperation fields to snake_case API body.
56
+ *
57
+ * @param {object} fields - payload, optional kind, occurredAt, operatorId, client
58
+ * @returns {object} PUT body for /operations/:key
59
+ */
60
+ function updateBody(fields) {
61
+ const data = {
62
+ payload: fields.payload
63
+ };
64
+ if (fields.kind !== undefined) {
65
+ data.kind = fields.kind;
66
+ }
67
+ if (fields.occurredAt !== undefined) {
68
+ data.occurred_at = fields.occurredAt;
69
+ }
70
+ attachAudit(data, fields);
71
+ return data;
72
+ }
73
+
74
+ /**
75
+ * Builds the query string for operations list filters.
76
+ *
77
+ * @param {object} [options] - optional kind, from, to
78
+ * @returns {string} query string including leading ? or empty
79
+ */
80
+ function listQuery(options) {
81
+ const params = new URLSearchParams();
82
+ if (options && options.kind) {
83
+ params.set('kind', options.kind);
84
+ }
85
+ if (options && options.from) {
86
+ params.set('from', options.from);
87
+ }
88
+ if (options && options.to) {
89
+ params.set('to', options.to);
90
+ }
91
+ const qs = params.toString();
92
+ return qs ? `?${qs}` : '';
93
+ }
94
+
95
+ /**
96
+ * Sends DELETE /operations/:key with optional audit JSON body.
97
+ *
98
+ * @param {function} request - authenticated JSON request helper
99
+ * @param {string} key - operation external key
100
+ * @param {object} [fields] - optional operatorId and client
101
+ * @returns {Promise<*>} delete response
102
+ */
103
+ function deleteRequest(request, key, fields) {
104
+ const data = {};
105
+ attachAudit(data, fields || {});
106
+ const path = `/operations/${encodeURIComponent(key)}`;
107
+ if (Object.keys(data).length === 0) {
108
+ return request(path, { method: 'DELETE' });
109
+ }
110
+ return request(path, payload('DELETE', data));
111
+ }
112
+
113
+ /**
114
+ * Machine operations list, create, update, delete, decisions, and SSE methods.
115
+ *
116
+ * @param {string} baseUrl - API base URL
117
+ * @param {function} request - authenticated JSON request helper
118
+ * @param {function} eventSource - EventSource constructor
119
+ * @param {object} [logger] - optional logger with error(tag, detail)
120
+ * @returns {object} operations client methods
121
+ *
122
+ * @example
123
+ * const ops = machineOperationsClient(baseUrl, request, EventSource, logger);
124
+ * await ops.createOperation({ kind: 'bath', payload: {}, operatorId: 2 });
125
+ * await ops.operationDecisions(key);
126
+ */
127
+ export default function machineOperationsClient(baseUrl, request, eventSource, logger) {
128
+ return {
129
+ operations(options) {
130
+ return request(`/operations${listQuery(options)}`).then((body) => {
131
+ return body.items;
132
+ });
133
+ },
134
+ createOperation(fields) {
135
+ return request('/operations', payload('POST', createBody(fields)));
136
+ },
137
+ updateOperation(key, fields) {
138
+ return request(
139
+ `/operations/${encodeURIComponent(key)}`,
140
+ payload('PUT', updateBody(fields))
141
+ );
142
+ },
143
+ deleteOperation(key, fields) {
144
+ return deleteRequest(request, key, fields);
145
+ },
146
+ operationDecisions(key) {
147
+ return request(`/operations/${encodeURIComponent(key)}/decisions`).then((body) => {
148
+ return body.items;
149
+ });
150
+ },
151
+ operationsStream(callback) {
152
+ const conn = sseConnection(`${baseUrl}/operations/stream`, eventSource, logger);
153
+ conn.on('operation_created', callback);
154
+ conn.on('operation_updated', callback);
155
+ conn.on('operation_deleted', callback);
156
+ return conn;
157
+ }
158
+ };
159
+ }
@@ -0,0 +1,67 @@
1
+ import machineClient from './machineClient.js';
2
+ import sseConnection from './sseConnection.js';
3
+
4
+ /**
5
+ * Main SCADA API client.
6
+ * Returns object with machines() and machine() methods.
7
+ *
8
+ * @param {string} baseUrl - server base URL
9
+ * @param {function} fetcher - fetch function
10
+ * @param {function} eventSource - EventSource constructor
11
+ * @param {object} [logger] - optional logger with error(tag, detail)
12
+ * @returns {object} client with machines, machine, jump, reset, simulation methods
13
+ *
14
+ * @example
15
+ * const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource, logger);
16
+ * const machines = await client.machines();
17
+ * const machine = client.machine('m1');
18
+ * await client.jump('2025-06-15T10:00:00Z');
19
+ * await client.reset();
20
+ */
21
+ export default function scadaClient(baseUrl, fetcher, eventSource, logger) {
22
+ async function requestJson(path, options) {
23
+ const fullPath = `${baseUrl}${path}`;
24
+ let response;
25
+ try {
26
+ response = await fetcher(fullPath, options);
27
+ } catch (cause) {
28
+ if (logger && typeof logger.error === 'function') {
29
+ logger.error('api.network', { path: fullPath, cause });
30
+ }
31
+ throw cause;
32
+ }
33
+ const payload = await response.json();
34
+ if (!response.ok) {
35
+ if (logger && typeof logger.error === 'function') {
36
+ logger.error('api', { path: fullPath, body: payload });
37
+ }
38
+ const error = new Error(payload.error.message);
39
+ error.code = payload.error.code;
40
+ throw error;
41
+ }
42
+ return payload;
43
+ }
44
+ return {
45
+ machines() {
46
+ return requestJson('/machines');
47
+ },
48
+ tagCatalog() {
49
+ return requestJson('/tag-catalog');
50
+ },
51
+ machine(machineId) {
52
+ return machineClient(baseUrl, machineId, fetcher, eventSource, logger);
53
+ },
54
+ jump(timestamp) {
55
+ return requestJson('/simulation/jump', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timestamp }) });
56
+ },
57
+ reset() {
58
+ return requestJson('/simulation', { method: 'DELETE' });
59
+ },
60
+ heartbeatStream() {
61
+ return sseConnection(`${baseUrl}/heartbeat/stream`, eventSource, logger);
62
+ },
63
+ simulation() {
64
+ return requestJson('/simulation');
65
+ }
66
+ };
67
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * SSE connection wrapper with event callbacks.
3
+ * Returns immutable object with on() and close() methods.
4
+ *
5
+ * @param {string} url - SSE endpoint URL
6
+ * @param {function} eventSource - EventSource constructor (for testing)
7
+ * @param {object} [logger] - optional logger with error(tag, detail)
8
+ * @returns {object} connection with on, close methods
9
+ *
10
+ * @example
11
+ * const conn = sseConnection(url, EventSource, logger);
12
+ * conn.on('measurement', data => console.log(data));
13
+ * conn.close();
14
+ */
15
+ export default function sseConnection(url, EventSourceCtor, logger) {
16
+ const source = new EventSourceCtor(url);
17
+ if (logger && typeof logger.error === 'function') {
18
+ source.onerror = function onerror() {
19
+ logger.error('sse.connection', { url });
20
+ };
21
+ }
22
+ return {
23
+ on(event, notify) {
24
+ source.addEventListener(event, (ev) => {
25
+ let payload;
26
+ try {
27
+ payload = JSON.parse(ev.data);
28
+ } catch (cause) {
29
+ if (logger && typeof logger.error === 'function') {
30
+ logger.error('sse.parse', { url, event, cause });
31
+ }
32
+ return;
33
+ }
34
+ notify(payload);
35
+ });
36
+ return this;
37
+ },
38
+ close() {
39
+ source.close();
40
+ }
41
+ };
42
+ }