@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,54 @@
1
+ import http from 'http';
2
+ import checkpointRoutes from './routes/checkpoint/checkpointRoutes.js';
3
+ import metricsRoutes from './routes/metrics/metricsRoutes.js';
4
+ import retentionRoutes from './routes/retention/retentionRoutes.js';
5
+ import { routes } from '@yarkivaev/simple-server';
6
+
7
+ /**
8
+ * Builds edge HTTP API (metrics, checkpoint, retention only).
9
+ *
10
+ * @param {object} dataAccess - metrics and checkpoints backends
11
+ * @param {object} [options] - token, retention, metrics flags
12
+ * @returns {object} routes with list() and handle()
13
+ */
14
+ export function createEdgeApi(dataAccess, options) {
15
+ const opt = options || {};
16
+ const token = Object.hasOwn(opt, 'token') ? opt.token : null;
17
+ const retentionDays = opt.retentionDays ?? 30;
18
+ const metricsEnabled = opt.metricsEnabled !== false;
19
+ const { metrics, checkpoints } = dataAccess;
20
+ const purge = opt.runRetention;
21
+ const admin = opt.retentionEnabled && opt.pool && purge
22
+ ? retentionRoutes(token, opt.pool, retentionDays, purge)
23
+ : [];
24
+ const metricRoutes = metricsEnabled
25
+ ? metricsRoutes(token, metrics)
26
+ : [];
27
+ return routes([
28
+ ...admin,
29
+ ...checkpointRoutes(token, checkpoints),
30
+ ...metricRoutes
31
+ ], { requestTimeoutMs: opt.requestTimeoutMs });
32
+ }
33
+
34
+ /**
35
+ * HTTP server for edge persistence API (/v1).
36
+ *
37
+ * @param {object} dataAccess - injected persistence backends
38
+ * @param {object} options - listen port, auth token, retention, metrics flags
39
+ * @returns {object} server with start() and stop()
40
+ */
41
+ export default function edgeApi(dataAccess, options) {
42
+ const api = createEdgeApi(dataAccess, options);
43
+ const server = http.createServer((req, res) => {
44
+ return api.handle(req, res);
45
+ });
46
+ return {
47
+ start() {
48
+ server.listen(options.port);
49
+ },
50
+ stop() {
51
+ server.close();
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Read port adapter for supervisor-sink metrics HTTP API.
3
+ *
4
+ * @param {object} client - stateHttpClient
5
+ * @returns {object} read port for metricsSensor
6
+ */
7
+ export default function httpMetricsRead(client) {
8
+ return {
9
+ async current(topic) {
10
+ const data = await client.getJson('/v1/metrics/current', { topic });
11
+ if (!data.found) {
12
+ return { found: false };
13
+ }
14
+ return { found: true, ts: data.ts, value: data.value };
15
+ },
16
+ async range(topic, startIso, endIso, stepMs) {
17
+ const data = await client.getJson('/v1/metrics/range', {
18
+ topic,
19
+ start: startIso,
20
+ end: endIso,
21
+ stepMs: String(stepMs)
22
+ });
23
+ return data.items;
24
+ },
25
+ async poll(topic, afterIso, untilIso) {
26
+ const data = await client.getJson('/v1/metrics/poll', {
27
+ topic,
28
+ after: afterIso,
29
+ until: untilIso
30
+ });
31
+ return data.items;
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,15 @@
1
+ import metricsSensor from '../../persistence/metricsSensor.js';
2
+ import httpMetricsRead from './httpMetricsRead.js';
3
+
4
+ /**
5
+ * Sensor reading metrics via supervisor-sink HTTP API.
6
+ *
7
+ * @param {object} client - stateHttpClient
8
+ * @param {string} topic - metrics topic key
9
+ * @param {string} displayName - label
10
+ * @param {string} unit - unit string
11
+ * @returns {object} sensor with name current measurements stream
12
+ */
13
+ export default function stateHttpMetricsSensor(client, topic, displayName, unit) {
14
+ return metricsSensor(httpMetricsRead(client), topic, displayName, unit);
15
+ }
@@ -0,0 +1,20 @@
1
+ import { parseRequestTimeoutMs } from '@yarkivaev/simple-server';
2
+
3
+ const DEFAULT_STATE_HTTP_TIMEOUT_MS = 25000;
4
+
5
+ /**
6
+ * Resolves outbound supervisor-sink client timeout from environment.
7
+ *
8
+ * @param {NodeJS.ProcessEnv} env - process environment
9
+ * @returns {number} timeout in milliseconds
10
+ */
11
+ export default function parseStateHttpTimeoutMs(env = process.env) {
12
+ if (env.STATE_HTTP_TIMEOUT_MS !== undefined && env.STATE_HTTP_TIMEOUT_MS !== '') {
13
+ return parseRequestTimeoutMs(env.STATE_HTTP_TIMEOUT_MS);
14
+ }
15
+ if (env.REQUEST_TIMEOUT_MS !== undefined && env.REQUEST_TIMEOUT_MS !== '') {
16
+ const total = parseRequestTimeoutMs(env.REQUEST_TIMEOUT_MS);
17
+ return Math.max(1000, Math.floor(total / 2));
18
+ }
19
+ return DEFAULT_STATE_HTTP_TIMEOUT_MS;
20
+ }
@@ -0,0 +1,79 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
3
+
4
+ function parseFrom(raw) {
5
+ const value = Number(raw);
6
+ return Number.isFinite(value) ? value : null;
7
+ }
8
+
9
+ function toTopicList(topicParam) {
10
+ if (Array.isArray(topicParam)) {
11
+ return topicParam;
12
+ }
13
+ if (typeof topicParam === 'string' && topicParam.length > 0) {
14
+ return [topicParam];
15
+ }
16
+ return [];
17
+ }
18
+
19
+ function segmentRoute(token, checkpointState) {
20
+ return route('GET', '/v1/checkpoint/segment', async (req, res, params, query) => {
21
+ void params;
22
+ if (!hasAccess(req, token)) {
23
+ sendForbidden(res);
24
+ return;
25
+ }
26
+ if (!query.machineId) {
27
+ errorResponse('BAD_REQUEST', 'machineId is required', 400).send(res);
28
+ return;
29
+ }
30
+ const start = parseFrom(query.start);
31
+ if (start === null) {
32
+ errorResponse('BAD_REQUEST', 'start must be a number', 400).send(res);
33
+ return;
34
+ }
35
+ const item = await checkpointState.segment(query.machineId, start);
36
+ jsonResponse({ item }).send(res);
37
+ });
38
+ }
39
+
40
+ export default function checkpointRoutes(token, checkpointState) {
41
+ return [
42
+ route('GET', '/v1/checkpoint/replay-cursor', async (req, res, params, query) => {
43
+ void params;
44
+ if (!hasAccess(req, token)) {
45
+ sendForbidden(res);
46
+ return;
47
+ }
48
+ if (!query.machineId) {
49
+ errorResponse('BAD_REQUEST', 'machineId is required', 400).send(res);
50
+ return;
51
+ }
52
+ const cursor = await checkpointState.replayCursor(query.machineId);
53
+ jsonResponse({ machineId: query.machineId, cursor }).send(res);
54
+ }),
55
+ segmentRoute(token, checkpointState),
56
+ route('GET', '/v1/checkpoint/pending-segments', async (req, res) => {
57
+ if (!hasAccess(req, token)) {
58
+ sendForbidden(res);
59
+ return;
60
+ }
61
+ const items = await checkpointState.pendingSegments();
62
+ jsonResponse({ items }).send(res);
63
+ }),
64
+ route('GET', '/v1/checkpoint/readings', async (req, res, params, query) => {
65
+ void params;
66
+ if (!hasAccess(req, token)) {
67
+ sendForbidden(res);
68
+ return;
69
+ }
70
+ const from = parseFrom(query.from);
71
+ if (from === null) {
72
+ errorResponse('BAD_REQUEST', 'from must be a number', 400).send(res);
73
+ return;
74
+ }
75
+ const items = await checkpointState.readings(toTopicList(query.topic), from);
76
+ jsonResponse({ items }).send(res);
77
+ })
78
+ ];
79
+ }
@@ -0,0 +1,59 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse, readBody } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * @param {string} raw - JSON body
6
+ * @returns {Array<{topic: string, ts: string|Date, value: number}>}
7
+ */
8
+ function itemsFromBody(raw) {
9
+ const parsed = JSON.parse(raw);
10
+ if (!parsed || !Array.isArray(parsed.items)) {
11
+ throw new Error('body must be an object with items array');
12
+ }
13
+ return parsed.items;
14
+ }
15
+
16
+ /**
17
+ * @param {import('http').IncomingMessage} req
18
+ * @param {import('http').ServerResponse} res
19
+ * @param {object} metrics - metrics state port
20
+ * @returns {Promise<void>}
21
+ */
22
+ async function runBatch(req, res, metrics) {
23
+ let items;
24
+ try {
25
+ items = itemsFromBody(await readBody(req));
26
+ } catch (err) {
27
+ errorResponse('BAD_REQUEST', String(err.message), 400).send(res);
28
+ return;
29
+ }
30
+ if (items.length === 0) {
31
+ jsonResponse({ inserted: 0 }).send(res);
32
+ return;
33
+ }
34
+ if (items.length > 500) {
35
+ errorResponse('BAD_REQUEST', 'items length exceeds maximum of 500', 400).send(res);
36
+ return;
37
+ }
38
+ await metrics.insertRows(items);
39
+ jsonResponse({ inserted: items.length }).send(res);
40
+ }
41
+
42
+ /**
43
+ * Batch insert into metrics (MQTT pipeline sink).
44
+ *
45
+ * @param {string|null} token - optional bearer token
46
+ * @param {object} metrics - metrics state port
47
+ * @returns {function} route handler
48
+ */
49
+ export default function metricsBatch(token, metrics) {
50
+ return async (req, res, params, query) => {
51
+ void params;
52
+ void query;
53
+ if (!hasAccess(req, token)) {
54
+ sendForbidden(res);
55
+ return;
56
+ }
57
+ await runBatch(req, res, metrics);
58
+ };
59
+ }
@@ -0,0 +1,29 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Latest metric row for a topic (same semantics as postgresSensor.current).
6
+ *
7
+ * @param {string|null} token - optional bearer token
8
+ * @param {object} metrics - metrics state port
9
+ * @returns {function} route handler
10
+ */
11
+ export default function metricsCurrent(token, metrics) {
12
+ return async (req, res, params, query) => {
13
+ void params;
14
+ if (!hasAccess(req, token)) {
15
+ sendForbidden(res);
16
+ return;
17
+ }
18
+ if (!query.topic) {
19
+ errorResponse('BAD_REQUEST', 'topic query parameter is required', 400).send(res);
20
+ return;
21
+ }
22
+ const row = await metrics.latestForTopic(query.topic);
23
+ if (!row) {
24
+ jsonResponse({ found: false }).send(res);
25
+ return;
26
+ }
27
+ jsonResponse({ found: true, ts: row.ts, value: row.value }).send(res);
28
+ };
29
+ }
@@ -0,0 +1,25 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Raw metric rows since a timestamp (postgresSensor.stream polling query).
6
+ *
7
+ * @param {string|null} token - optional bearer token
8
+ * @param {object} metrics - metrics state port
9
+ * @returns {function} route handler
10
+ */
11
+ export default function metricsPoll(token, metrics) {
12
+ return async (req, res, params, query) => {
13
+ void params;
14
+ if (!hasAccess(req, token)) {
15
+ sendForbidden(res);
16
+ return;
17
+ }
18
+ if (!query.topic || !query.after || !query.until) {
19
+ errorResponse('BAD_REQUEST', 'topic after and until query parameters are required', 400).send(res);
20
+ return;
21
+ }
22
+ const items = await metrics.pollTopic(query.topic, query.after, query.until);
23
+ jsonResponse({ items }).send(res);
24
+ };
25
+ }
@@ -0,0 +1,26 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse } from '@yarkivaev/simple-server';
3
+
4
+ /**
5
+ * Downsampled metric range (same date_bin semantics as postgresSensor.measurements).
6
+ *
7
+ * @param {string|null} token - optional bearer token
8
+ * @param {object} metrics - metrics state port
9
+ * @returns {function} route handler
10
+ */
11
+ export default function metricsRange(token, metrics) {
12
+ return async (req, res, params, query) => {
13
+ void params;
14
+ if (!hasAccess(req, token)) {
15
+ sendForbidden(res);
16
+ return;
17
+ }
18
+ if (!query.topic || !query.start || !query.end) {
19
+ errorResponse('BAD_REQUEST', 'topic start and end query parameters are required', 400).send(res);
20
+ return;
21
+ }
22
+ const stepMs = parseInt(query.stepMs || '60000', 10);
23
+ const items = await metrics.rangeForTopic(query.topic, query.start, query.end, stepMs);
24
+ jsonResponse({ items }).send(res);
25
+ };
26
+ }
@@ -0,0 +1,21 @@
1
+ import metricsBatch from './metricsBatch.js';
2
+ import metricsCurrent from './metricsCurrent.js';
3
+ import metricsPoll from './metricsPoll.js';
4
+ import metricsRange from './metricsRange.js';
5
+ import { route } from '@yarkivaev/simple-server';
6
+
7
+ /**
8
+ * Metrics read/write routes for /v1.
9
+ *
10
+ * @param {string|null} token - optional bearer token
11
+ * @param {object} metrics - metrics state port
12
+ * @returns {Array<object>} route definitions
13
+ */
14
+ export default function metricsRoutes(token, metrics) {
15
+ return [
16
+ route('GET', '/v1/metrics/current', metricsCurrent(token, metrics)),
17
+ route('GET', '/v1/metrics/range', metricsRange(token, metrics)),
18
+ route('GET', '/v1/metrics/poll', metricsPoll(token, metrics)),
19
+ route('POST', '/v1/metrics/batch', metricsBatch(token, metrics))
20
+ ];
21
+ }
@@ -0,0 +1,41 @@
1
+ import { hasAccess, sendForbidden } from '../../stateAccess.js';
2
+ import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
3
+
4
+ function parseDays(raw, fallback) {
5
+ if (raw === undefined || raw === '') {
6
+ return fallback;
7
+ }
8
+ const value = Number(raw);
9
+ if (!Number.isInteger(value) || value < 1) {
10
+ return null;
11
+ }
12
+ return value;
13
+ }
14
+
15
+ /**
16
+ * Admin retention route for scheduled database cleanup.
17
+ *
18
+ * @param {string|null} token - optional bearer token
19
+ * @param {object} pool - database pool passed to purge callback
20
+ * @param {number} defaultDays - retention window from deployment env
21
+ * @param {function} purge - async (pool, days) => cleanup result
22
+ * @returns {Array<object>} route definitions
23
+ */
24
+ export default function retentionRoutes(token, pool, defaultDays, purge) {
25
+ return [
26
+ route('POST', '/v1/admin/retention', async (req, res, params, query) => {
27
+ void params;
28
+ if (!hasAccess(req, token)) {
29
+ sendForbidden(res);
30
+ return;
31
+ }
32
+ const days = parseDays(query.days, defaultDays);
33
+ if (days === null) {
34
+ errorResponse('BAD_REQUEST', 'days must be a positive integer', 400).send(res);
35
+ return;
36
+ }
37
+ const body = await purge(pool, days);
38
+ jsonResponse(body).send(res);
39
+ })
40
+ ];
41
+ }
@@ -0,0 +1,39 @@
1
+ import http from 'http';
2
+ import { createEdgeApi } from './edgeApi.js';
3
+
4
+ /**
5
+ * Starts a test edge HTTP server on ephemeral port.
6
+ *
7
+ * @param {object} dataAccess - persistence backends
8
+ * @param {object} options - token, port
9
+ * @returns {Promise<{baseUrl: string, stop: function}>}
10
+ */
11
+ export default async function startTestEdgeApi(dataAccess, options) {
12
+ const opt = options || {};
13
+ const token = Object.hasOwn(opt, 'token') ? opt.token : null;
14
+ const port = Object.hasOwn(opt, 'port') ? opt.port : 0;
15
+ const api = createEdgeApi(dataAccess, { token });
16
+ const server = http.createServer((req, res) => {
17
+ return api.handle(req, res);
18
+ });
19
+ await new Promise((resolve, reject) => {
20
+ server.once('error', reject);
21
+ server.listen(port, '127.0.0.1', () => {
22
+ resolve();
23
+ });
24
+ });
25
+ const addr = server.address();
26
+ const baseUrl = `http://127.0.0.1:${addr.port}`;
27
+ async function stop() {
28
+ await new Promise((resolve, reject) => {
29
+ server.close((err) => {
30
+ if (err) {
31
+ reject(err);
32
+ } else {
33
+ resolve();
34
+ }
35
+ });
36
+ });
37
+ }
38
+ return { baseUrl, stop };
39
+ }
@@ -0,0 +1,13 @@
1
+ import { errorResponse } from '@yarkivaev/simple-server';
2
+
3
+ export function hasAccess(request, token) {
4
+ if (!token) {
5
+ return true;
6
+ }
7
+ const header = request.headers.authorization || '';
8
+ return header === `Bearer ${token}`;
9
+ }
10
+
11
+ export function sendForbidden(res) {
12
+ errorResponse('FORBIDDEN', 'forbidden', 403).send(res);
13
+ }
@@ -0,0 +1,106 @@
1
+ import stateHttpTimeoutError from './stateHttpTimeoutError.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 25000;
4
+
5
+ function trimBase(url) {
6
+ return url.replace(/\/$/u, '');
7
+ }
8
+
9
+ function authHeaders(token, json) {
10
+ const headers = {};
11
+ if (json) {
12
+ headers['Content-Type'] = 'application/json';
13
+ }
14
+ if (token) {
15
+ headers.Authorization = `Bearer ${token}`;
16
+ }
17
+ return headers;
18
+ }
19
+
20
+ async function readError(res) {
21
+ const text = await res.text();
22
+ return text.length > 0 ? text : res.statusText;
23
+ }
24
+
25
+ function buildUrl(base, path, query) {
26
+ const qs = new URLSearchParams(query).toString();
27
+ return qs ? `${base}${path}?${qs}` : `${base}${path}`;
28
+ }
29
+
30
+ async function expectJson(res, verb, path) {
31
+ if (!res.ok) {
32
+ throw new Error(`supervisor state ${verb} ${path} failed: ${res.status} ${await readError(res)}`);
33
+ }
34
+ const payload = await res.json();
35
+ return payload;
36
+ }
37
+
38
+ function resolveTimeoutMs(options) {
39
+ if (options.timeoutMs === undefined || options.timeoutMs === null) {
40
+ return DEFAULT_TIMEOUT_MS;
41
+ }
42
+ const ms = Number(options.timeoutMs);
43
+ if (!Number.isFinite(ms) || ms <= 0) {
44
+ return DEFAULT_TIMEOUT_MS;
45
+ }
46
+ return ms;
47
+ }
48
+
49
+ function isAbortTimeout(err) {
50
+ return err && (err.name === 'TimeoutError' || err.name === 'AbortError');
51
+ }
52
+
53
+ async function fetchJson(url, init, verb, path, limit) {
54
+ const signal = AbortSignal.timeout(limit);
55
+ try {
56
+ return await fetch(url, { ...init, signal });
57
+ } catch (err) {
58
+ if (isAbortTimeout(err)) {
59
+ throw stateHttpTimeoutError(verb, path, limit);
60
+ }
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * HTTP client for supervisor-sink system state API (/v1).
67
+ *
68
+ * @param {object} options - connection options
69
+ * @param {string} options.baseUrl - origin (no trailing slash)
70
+ * @param {string} [options.token] - optional Bearer token
71
+ * @param {number} [options.timeoutMs] - per-request deadline (default 25000)
72
+ * @returns {object} frozen client with getJson patchJson postJson
73
+ *
74
+ * @example
75
+ * const client = stateHttpClient({ baseUrl: 'http://localhost:8081', token: 'secret' });
76
+ * const data = await client.getJson('/v1/alerts', {});
77
+ */
78
+ export default function stateHttpClient(options) {
79
+ const { baseUrl: rawBase, token } = options;
80
+ const limit = resolveTimeoutMs(options);
81
+ const base = trimBase(rawBase);
82
+ async function getJson(path, query) {
83
+ const url = buildUrl(base, path, query);
84
+ const res = await fetchJson(url, { headers: authHeaders(token, false) }, 'GET', path, limit);
85
+ return expectJson(res, 'GET', path);
86
+ }
87
+ async function patchJson(path, body) {
88
+ const url = `${base}${path}`;
89
+ const res = await fetchJson(url, {
90
+ method: 'PATCH',
91
+ headers: authHeaders(token, true),
92
+ body: JSON.stringify(body)
93
+ }, 'PATCH', path, limit);
94
+ return expectJson(res, 'PATCH', path);
95
+ }
96
+ async function postJson(path, body) {
97
+ const url = `${base}${path}`;
98
+ const res = await fetchJson(url, {
99
+ method: 'POST',
100
+ headers: authHeaders(token, true),
101
+ body: JSON.stringify(body)
102
+ }, 'POST', path, limit);
103
+ return expectJson(res, 'POST', path);
104
+ }
105
+ return Object.freeze({ getJson, patchJson, postJson, baseUrl: base, timeoutMs: limit });
106
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error for supervisor-sink HTTP client deadline exceeded.
3
+ *
4
+ * @param {string} verb - HTTP method
5
+ * @param {string} path - request path
6
+ * @param {number} limit - timeout milliseconds
7
+ * @returns {Error} error with code TIMEOUT
8
+ */
9
+ export default function stateHttpTimeoutError(verb, path, limit) {
10
+ const err = new Error(`supervisor state ${verb} ${path} exceeded ${limit}ms`);
11
+ err.code = 'TIMEOUT';
12
+ return err;
13
+ }
@@ -0,0 +1,44 @@
1
+ function iso(value) {
2
+ if (value instanceof Date) {
3
+ return value.toISOString();
4
+ }
5
+ return new Date(value).toISOString();
6
+ }
7
+
8
+ function body(raw) {
9
+ if (typeof raw !== 'string') {
10
+ return raw;
11
+ }
12
+ return JSON.parse(raw);
13
+ }
14
+
15
+ /**
16
+ * Maps a user_decisions audit row to Plant API JSON item.
17
+ *
18
+ * @param {object} row - username, operatorId, decidedAt, payload
19
+ * @returns {object} JSON-serializable decision with operator display and tags
20
+ *
21
+ * @example
22
+ * decisionJson({
23
+ * username: 'Elena Volkov', operatorId: 2,
24
+ * decidedAt: new Date('2024-06-01T12:05:00.000Z'),
25
+ * payload: '{"tags":["charge_loading"]}'
26
+ * });
27
+ */
28
+ export default function decisionJson(row) {
29
+ const payload = body(row.payload);
30
+ const item = {
31
+ operator: row.username,
32
+ payload
33
+ };
34
+ if (row.operatorId !== undefined && row.operatorId !== null) {
35
+ item.operatorId = row.operatorId;
36
+ }
37
+ if (row.decidedAt !== undefined && row.decidedAt !== null) {
38
+ item.decidedAt = iso(row.decidedAt);
39
+ }
40
+ if (payload && Array.isArray(payload.tags)) {
41
+ item.tags = payload.tags;
42
+ }
43
+ return item;
44
+ }
@@ -0,0 +1,28 @@
1
+ function iso(value) {
2
+ if (value instanceof Date) {
3
+ return value.toISOString();
4
+ }
5
+ return new Date(value).toISOString();
6
+ }
7
+
8
+ /**
9
+ * Maps persistence operation row to Plant API JSON item.
10
+ *
11
+ * @param {object} row - operation row from persistence
12
+ * @returns {object} API item with external_key and source_updated_at
13
+ *
14
+ * @example
15
+ * operationJson({ key: 'nb-1', occurred_at: new Date(), kind: 'chem', payload: {}, machine: 'm1' });
16
+ */
17
+ export default function operationJson(row) {
18
+ const occurred = iso(row.occurred_at);
19
+ const updated = row.source_updated_at ? iso(row.source_updated_at) : occurred;
20
+ return {
21
+ external_key: row.key,
22
+ occurred_at: occurred,
23
+ kind: row.kind,
24
+ payload: row.payload,
25
+ machine: row.machine,
26
+ source_updated_at: updated
27
+ };
28
+ }