@yarkivaev/scada 1.4.0 → 2.3.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -33
- package/db/migrations/C0001__central_drop_metrics.sql +1 -0
- package/db/migrations/C0002__central_revoke_delete.sql +12 -0
- package/db/migrations/C0003__central_operators.sql +7 -0
- package/db/migrations/C0004__central_operations_grants.sql +2 -0
- package/db/migrations/C0005__central_operators_grants.sql +1 -0
- package/db/migrations/C0006__central_operators_registration.sql +16 -0
- package/db/migrations/E0001__edge_metrics.sql +8 -0
- package/db/migrations/E0002__edge_retention_delete.sql +8 -0
- package/db/migrations/E0003__edge_operations_grants.sql +2 -0
- package/db/migrations/V0001__baseline.sql +25 -0
- package/db/migrations/V0002__sink_extension_tables.sql +6 -0
- package/db/migrations/V0003__operations.sql +10 -0
- package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
- package/db/migrations/V0005__user_decisions_operator.sql +2 -0
- package/index.js +75 -45
- package/package.json +28 -8
- package/src/application/bindSilentStreams.js +20 -0
- package/src/application/edgeOperatorCatalog.js +45 -0
- package/src/application/export/exportJob.js +118 -0
- package/src/application/export/exportQuery.js +32 -0
- package/src/application/export/exportSink.js +25 -0
- package/src/application/export/exportStream.js +33 -0
- package/src/application/machineInPlant.js +20 -0
- package/src/application/metricsPlant.js +35 -0
- package/src/application/plantApi.js +49 -0
- package/src/application/plantOperations.js +19 -0
- package/src/application/plantServer.js +107 -0
- package/src/application/shopWithTimeline.js +112 -0
- package/src/application/siteOperatorCatalog.js +78 -0
- package/src/application/siteServer.js +191 -0
- package/src/application/supervisorSink.js +97 -0
- package/src/application/timelineOperatorFromEnv.js +19 -0
- package/src/bin/supervisor-sink.js +28 -0
- package/src/{alert.js → domain/alerting/alert.js} +2 -2
- package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
- package/src/domain/alerting/ingest.js +18 -0
- package/src/domain/ingest/ingestCursor.js +26 -0
- package/src/domain/operation/operation.js +23 -0
- package/src/domain/operation/operations.js +81 -0
- package/src/domain/operator/operator.js +23 -0
- package/src/domain/plant/machine.js +32 -0
- package/src/domain/plant/plant.js +20 -0
- package/src/domain/plant/shop.js +24 -0
- package/src/domain/segment/dispatch.js +31 -0
- package/src/domain/segment/normalize.js +31 -0
- package/src/domain/segment/silenceBudget.js +16 -0
- package/src/{initialized.js → domain/shared/initialized.js} +3 -4
- package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
- package/src/domain/timeline/timeline.js +25 -0
- package/src/infrastructure/catalog/tag-catalog.json +27 -0
- package/src/infrastructure/catalog/tagCatalog.js +65 -0
- package/src/infrastructure/client/index.js +20 -0
- package/src/infrastructure/client/machineClient.js +159 -0
- package/src/infrastructure/client/machineOperationsClient.js +159 -0
- package/src/infrastructure/client/scadaClient.js +67 -0
- package/src/infrastructure/client/sseConnection.js +42 -0
- package/src/infrastructure/http/edge/edgeApi.js +54 -0
- package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
- package/src/infrastructure/http/edge/metricsSensor.js +15 -0
- package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
- package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
- package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
- package/src/infrastructure/http/edge/stateAccess.js +13 -0
- package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
- package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
- package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
- package/src/infrastructure/http/plant/json/operationJson.js +28 -0
- package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
- package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
- package/src/infrastructure/http/plant/operationAudit.js +56 -0
- package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
- package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
- package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
- package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
- package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
- package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
- package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
- package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
- package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
- package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
- package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
- package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
- package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
- package/src/infrastructure/http/plant/timelineOperator.js +87 -0
- package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
- package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
- package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
- package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
- package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
- package/src/infrastructure/ingest/cooldown.js +24 -0
- package/src/infrastructure/ingest/db/migrate.js +78 -0
- package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
- package/src/infrastructure/ingest/db/runRetention.js +58 -0
- package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
- package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
- package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
- package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
- package/src/infrastructure/ingest/processingErrorLog.js +25 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
- package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
- package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
- package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
- package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
- package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
- package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
- package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
- package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
- package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
- package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
- package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
- package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
- package/src/infrastructure/messaging/stomp/timeline.js +21 -0
- package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
- package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
- package/src/infrastructure/operators/centralOperators.js +64 -0
- package/src/infrastructure/operators/edgeOperators.js +39 -0
- package/src/infrastructure/operators/operatorById.js +33 -0
- package/src/infrastructure/operators/operatorExtras.js +41 -0
- package/src/infrastructure/operators/operators.js +32 -0
- package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
- package/src/infrastructure/operators/operatorsSync.js +57 -0
- package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
- package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
- package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
- package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
- package/src/infrastructure/persistence/memory/alerts.js +21 -0
- package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
- package/src/infrastructure/persistence/memory/metrics.js +69 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
- package/src/infrastructure/persistence/memory/operations.js +87 -0
- package/src/infrastructure/persistence/memory/segments.js +83 -0
- package/src/infrastructure/persistence/memory/timeline.js +56 -0
- package/src/infrastructure/persistence/metricsSensor.js +112 -0
- package/src/infrastructure/persistence/pg/alerts.js +25 -0
- package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
- package/src/infrastructure/persistence/pg/metrics.js +73 -0
- package/src/infrastructure/persistence/pg/operations.js +95 -0
- package/src/infrastructure/persistence/pg/operators.js +118 -0
- package/src/infrastructure/persistence/pg/segments.js +48 -0
- package/src/infrastructure/persistence/pg/timeline.js +53 -0
- package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
- package/src/infrastructure/persistence/postgresPool.js +24 -0
- package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
- package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
- package/src/infrastructure/sync/operationCodec.js +75 -0
- package/src/infrastructure/sync/operationSyncIngest.js +82 -0
- package/src/infrastructure/sync/operationSyncSink.js +40 -0
- package/src/activeMelting.js +0 -54
- package/src/completedMelting.js +0 -42
- package/src/event.js +0 -24
- package/src/events.js +0 -51
- package/src/interval.js +0 -18
- package/src/machineChronology.js +0 -79
- package/src/meltingChronology.js +0 -37
- package/src/meltingMachine.js +0 -53
- package/src/meltingRuleEngine.js +0 -37
- package/src/meltingShop.js +0 -32
- package/src/meltings.js +0 -97
- package/src/monitoredMeltingMachine.js +0 -33
- package/src/plant.js +0 -23
- package/src/requests.js +0 -44
- package/src/rule.js +0 -33
- package/src/rules.js +0 -23
- package/src/scyllaSensor.js +0 -54
- package/src/segments.js +0 -64
- package/src/sqliteSensor.js +0 -106
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import operationJson from '../json/operationJson.js';
|
|
3
|
+
import { decisionRow, stampPayload } from '../operationAudit.js';
|
|
4
|
+
import { draftFromBody, draftFromUpdate } from './operationDrafts.js';
|
|
5
|
+
import httpOperations from '../../../messaging/ownership/httpOperations.js';
|
|
6
|
+
import { errorResponse, jsonResponse, readBody, sendRouteError } from '@yarkivaev/simple-server';
|
|
7
|
+
|
|
8
|
+
function isMissing(err) {
|
|
9
|
+
return typeof err.message === 'string' && err.message.includes('not found for machine');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sendFailure(gate, res, err) {
|
|
13
|
+
if (gate && gate.sendError(res, err)) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (err.routeCode && err.routeStatus) {
|
|
17
|
+
errorResponse(err.routeCode, err.message, err.routeStatus).send(res);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (isMissing(err)) {
|
|
21
|
+
errorResponse('NOT_FOUND', err.message, 404).send(res);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
sendRouteError(res, err);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function machineMissing(plant, machineId, res) {
|
|
28
|
+
const result = machineInPlant(plant, machineId);
|
|
29
|
+
if (!result || !plant.operations) {
|
|
30
|
+
errorResponse('NOT_FOUND', `Machine '${machineId}' not found`, 404).send(res);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function record(decisions, machine, item, audit, verb) {
|
|
37
|
+
if (!decisions || typeof decisions.insert !== 'function') {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await decisions.insert(decisionRow(machine, item, audit, verb));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readJson(req) {
|
|
44
|
+
if (typeof req.on !== 'function') {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
const raw = await readBody(req);
|
|
48
|
+
if (!raw || String(raw).trim().length === 0) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function edgePort(owners, machineId) {
|
|
55
|
+
if (!owners || typeof owners.resolve !== 'function') {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const owner = owners.resolve(machineId);
|
|
59
|
+
if (!owner || owner.kind !== 'edge') {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return httpOperations(owner, machineId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function forwardBody(parsed, audit) {
|
|
66
|
+
const body = { ...parsed };
|
|
67
|
+
if (audit.id !== undefined && audit.id !== null) {
|
|
68
|
+
body.operatorId = audit.id;
|
|
69
|
+
}
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ownerItem(machineId, key, response, parsed) {
|
|
74
|
+
return {
|
|
75
|
+
machine: machineId,
|
|
76
|
+
key: (response && (response.external_key || response.key)) || key || parsed.key,
|
|
77
|
+
kind: (response && response.kind) || parsed.kind,
|
|
78
|
+
occurred_at: (response && response.occurred_at) || parsed.occurred_at || new Date(),
|
|
79
|
+
payload: (response && response.payload) || parsed.payload
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function localCreate(ctx, machineId, parsed, audit) {
|
|
84
|
+
const item = draftFromBody(machineId, parsed);
|
|
85
|
+
item.payload = stampPayload(item.payload, audit);
|
|
86
|
+
await ctx.plant.operations.upsert(item);
|
|
87
|
+
await record(ctx.decisions, machineId, item, audit, 'create');
|
|
88
|
+
return item;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function localUpdate(ctx, machineId, key, parsed, audit) {
|
|
92
|
+
const existing = await ctx.plant.operations.get(machineId, key);
|
|
93
|
+
const item = draftFromUpdate(machineId, key, existing, parsed);
|
|
94
|
+
item.payload = stampPayload(item.payload, audit);
|
|
95
|
+
await ctx.plant.operations.upsert(item);
|
|
96
|
+
await record(ctx.decisions, machineId, item, audit, 'update');
|
|
97
|
+
return item;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function localDelete(ctx, machineId, key, audit) {
|
|
101
|
+
const item = await ctx.plant.operations.remove(machineId, key);
|
|
102
|
+
await record(ctx.decisions, machineId, item, audit, 'delete');
|
|
103
|
+
return item;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeCreate(ctx, machineId, req, res) {
|
|
107
|
+
if (machineMissing(ctx.plant, machineId, res)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const parsed = await readJson(req);
|
|
112
|
+
const audit = await ctx.gate.resolve(parsed);
|
|
113
|
+
const port = edgePort(ctx.owners, machineId);
|
|
114
|
+
if (port) {
|
|
115
|
+
const created = await port.create(forwardBody(parsed, audit));
|
|
116
|
+
await record(ctx.decisions, machineId, ownerItem(machineId, undefined, created, parsed), audit, 'create');
|
|
117
|
+
jsonResponse(created).send(res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
jsonResponse(operationJson(await localCreate(ctx, machineId, parsed, audit))).send(res);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
sendFailure(ctx.gate, res, err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function writeUpdate(ctx, machineId, key, req, res) {
|
|
127
|
+
if (machineMissing(ctx.plant, machineId, res)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const parsed = await readJson(req);
|
|
132
|
+
const audit = await ctx.gate.resolve(parsed);
|
|
133
|
+
const port = edgePort(ctx.owners, machineId);
|
|
134
|
+
if (port) {
|
|
135
|
+
const updated = await port.update(key, forwardBody(parsed, audit));
|
|
136
|
+
await record(ctx.decisions, machineId, ownerItem(machineId, key, updated, parsed), audit, 'update');
|
|
137
|
+
jsonResponse(updated).send(res);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
jsonResponse(operationJson(await localUpdate(ctx, machineId, key, parsed, audit))).send(res);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
sendFailure(ctx.gate, res, err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeDelete(ctx, machineId, key, req, res) {
|
|
147
|
+
if (machineMissing(ctx.plant, machineId, res)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const parsed = await readJson(req);
|
|
152
|
+
const audit = await ctx.gate.resolve(parsed);
|
|
153
|
+
const port = edgePort(ctx.owners, machineId);
|
|
154
|
+
if (port) {
|
|
155
|
+
const removed = await port.remove(key, forwardBody(parsed, audit));
|
|
156
|
+
await record(ctx.decisions, machineId, ownerItem(machineId, key, removed, parsed), audit, 'delete');
|
|
157
|
+
jsonResponse(removed).send(res);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
jsonResponse(operationJson(await localDelete(ctx, machineId, key, audit))).send(res);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
sendFailure(ctx.gate, res, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Builds create/update/delete handlers for operationRoute.
|
|
168
|
+
*
|
|
169
|
+
* Edge-owned machines proxy to the owning plant API without local upsert.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} ctx - plant, gate, decisions, owners
|
|
172
|
+
* @returns {object} writeCreate, writeUpdate, writeDelete
|
|
173
|
+
*/
|
|
174
|
+
export default function operationWrites(ctx) {
|
|
175
|
+
return {
|
|
176
|
+
writeCreate(machineId, req, res) {
|
|
177
|
+
return writeCreate(ctx, machineId, req, res);
|
|
178
|
+
},
|
|
179
|
+
writeUpdate(machineId, key, req, res) {
|
|
180
|
+
return writeUpdate(ctx, machineId, key, req, res);
|
|
181
|
+
},
|
|
182
|
+
writeDelete(machineId, key, req, res) {
|
|
183
|
+
return writeDelete(ctx, machineId, key, req, res);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import operatorJson from '../json/operatorJson.js';
|
|
2
|
+
import operatorExtras, { operatorDraftKeys } from '../../../operators/operatorExtras.js';
|
|
3
|
+
import { errorResponse, jsonResponse, readBody, route, sendRouteError } from '@yarkivaev/simple-server';
|
|
4
|
+
|
|
5
|
+
function routeFailure(code, message, status) {
|
|
6
|
+
const err = new Error(message);
|
|
7
|
+
err.routeCode = code;
|
|
8
|
+
err.routeStatus = status;
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sendKnown(res, err) {
|
|
13
|
+
if (err && err.routeCode && err.routeStatus) {
|
|
14
|
+
errorResponse(err.routeCode, err.message, err.routeStatus).send(res);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function report(logger, message, err) {
|
|
21
|
+
if (logger && typeof logger.error === 'function') {
|
|
22
|
+
logger.error(message, err);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.error(message, err); // eslint-disable-line no-console
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeUid(raw) {
|
|
29
|
+
return String(raw).trim().toUpperCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function draftFromBody(parsed) {
|
|
33
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
34
|
+
throw routeFailure('BAD_REQUEST', 'operator body must be a JSON object', 400);
|
|
35
|
+
}
|
|
36
|
+
const { firstName, lastName, displayName } = parsed;
|
|
37
|
+
const cardUid = normalizeUid(parsed.cardUid === undefined || parsed.cardUid === null ? '' : parsed.cardUid);
|
|
38
|
+
if (!cardUid) {
|
|
39
|
+
throw routeFailure('BAD_REQUEST', 'cardUid is required', 400);
|
|
40
|
+
}
|
|
41
|
+
if (typeof firstName !== 'string' || !firstName.trim()) {
|
|
42
|
+
throw routeFailure('BAD_REQUEST', 'firstName is required', 400);
|
|
43
|
+
}
|
|
44
|
+
if (typeof lastName !== 'string' || !lastName.trim()) {
|
|
45
|
+
throw routeFailure('BAD_REQUEST', 'lastName is required', 400);
|
|
46
|
+
}
|
|
47
|
+
if (typeof displayName !== 'string' || !displayName.trim()) {
|
|
48
|
+
throw routeFailure('BAD_REQUEST', 'displayName is required', 400);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
cardUid,
|
|
52
|
+
firstName,
|
|
53
|
+
lastName,
|
|
54
|
+
displayName,
|
|
55
|
+
...operatorExtras(parsed, operatorDraftKeys())
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function flagFromBody(parsed) {
|
|
60
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
61
|
+
throw routeFailure('BAD_REQUEST', 'registration body must be a JSON object', 400);
|
|
62
|
+
}
|
|
63
|
+
if (typeof parsed.enabled !== 'boolean') {
|
|
64
|
+
throw routeFailure('BAD_REQUEST', 'enabled must be a boolean', 400);
|
|
65
|
+
}
|
|
66
|
+
return parsed.enabled;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function handleGetEnabled(provider, logger, basePath, res) {
|
|
70
|
+
try {
|
|
71
|
+
const enabled = await provider.enabled();
|
|
72
|
+
jsonResponse({ enabled }).send(res);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
report(logger, `GET ${basePath}/operators/registration-enabled failed: ${err && err.message ? err.message : 'unknown error'}`, err);
|
|
75
|
+
sendRouteError(res, err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handlePutEnabled(provider, logger, basePath, req, res) {
|
|
80
|
+
try {
|
|
81
|
+
const enabled = flagFromBody(JSON.parse(await readBody(req)));
|
|
82
|
+
const stored = await provider.permit(enabled);
|
|
83
|
+
jsonResponse({ enabled: stored }).send(res);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (sendKnown(res, err)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
report(logger, `PUT ${basePath}/operators/registration-enabled failed: ${err && err.message ? err.message : 'unknown error'}`, err);
|
|
89
|
+
sendRouteError(res, err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleList(provider, logger, basePath, res) {
|
|
94
|
+
try {
|
|
95
|
+
const rows = await provider.list();
|
|
96
|
+
jsonResponse({ items: rows.map(operatorJson) }).send(res);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
report(logger, `GET ${basePath}/operators failed: ${err && err.message ? err.message : 'unknown error'}`, err);
|
|
99
|
+
sendRouteError(res, err);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleCreate(provider, logger, basePath, req, res) {
|
|
104
|
+
try {
|
|
105
|
+
const draft = draftFromBody(JSON.parse(await readBody(req)));
|
|
106
|
+
const created = await provider.create(draft);
|
|
107
|
+
jsonResponse(operatorJson(created)).send(res);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (sendKnown(res, err)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
report(logger, `POST ${basePath}/operators failed: ${err && err.message ? err.message : 'unknown error'}`, err);
|
|
113
|
+
sendRouteError(res, err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Operator catalog and registration routes for central plant API.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} basePath - base URL path
|
|
121
|
+
* @param {object} provider - operators provider with list, create, enabled, permit
|
|
122
|
+
* @param {object} [logger] - optional logger with error(message, err)
|
|
123
|
+
* @returns {array} route objects
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* operatorRoute('/api/v1', operatorsFromPg(pool));
|
|
127
|
+
*/
|
|
128
|
+
export default function operatorRoute(basePath, provider, logger) {
|
|
129
|
+
return [
|
|
130
|
+
route('GET', `${basePath}/operators/registration-enabled`, async (req, res) => {
|
|
131
|
+
await handleGetEnabled(provider, logger, basePath, res);
|
|
132
|
+
}),
|
|
133
|
+
route('PUT', `${basePath}/operators/registration-enabled`, async (req, res) => {
|
|
134
|
+
await handlePutEnabled(provider, logger, basePath, req, res);
|
|
135
|
+
}),
|
|
136
|
+
route('GET', `${basePath}/operators`, async (req, res) => {
|
|
137
|
+
await handleList(provider, logger, basePath, res);
|
|
138
|
+
}),
|
|
139
|
+
route('POST', `${basePath}/operators`, async (req, res) => {
|
|
140
|
+
await handleCreate(provider, logger, basePath, req, res);
|
|
141
|
+
})
|
|
142
|
+
];
|
|
143
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simulation control routes factory.
|
|
5
|
+
* Creates routes for jumping, resetting, and querying virtual clock.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - base URL path
|
|
8
|
+
* @param {function} clock - virtual clock with jump, reset, offset methods
|
|
9
|
+
* @returns {array} array of route objects
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const routes = simulationRoute('/api', virtualClock(() => new Date()));
|
|
13
|
+
*/
|
|
14
|
+
export default function simulationRoute(basePath, clock) {
|
|
15
|
+
return [
|
|
16
|
+
route(
|
|
17
|
+
'POST',
|
|
18
|
+
`${basePath}/simulation/jump`,
|
|
19
|
+
(req, res) => {
|
|
20
|
+
let body = '';
|
|
21
|
+
req.on('data', (chunk) => {
|
|
22
|
+
body += chunk;
|
|
23
|
+
});
|
|
24
|
+
req.on('end', () => {
|
|
25
|
+
const parsed = JSON.parse(body);
|
|
26
|
+
const target = new Date(parsed.timestamp);
|
|
27
|
+
if (isNaN(target.getTime())) {
|
|
28
|
+
errorResponse('BAD_REQUEST', 'Invalid timestamp', 400).send(res);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
clock.jump(target);
|
|
32
|
+
jsonResponse({
|
|
33
|
+
timestamp: clock().toISOString(),
|
|
34
|
+
offset: clock.offset()
|
|
35
|
+
}).send(res);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
),
|
|
39
|
+
route(
|
|
40
|
+
'DELETE',
|
|
41
|
+
`${basePath}/simulation`,
|
|
42
|
+
(req, res) => {
|
|
43
|
+
clock.reset();
|
|
44
|
+
jsonResponse({
|
|
45
|
+
timestamp: clock().toISOString(),
|
|
46
|
+
offset: clock.offset()
|
|
47
|
+
}).send(res);
|
|
48
|
+
}
|
|
49
|
+
),
|
|
50
|
+
route(
|
|
51
|
+
'GET',
|
|
52
|
+
`${basePath}/simulation`,
|
|
53
|
+
(req, res) => {
|
|
54
|
+
jsonResponse({
|
|
55
|
+
timestamp: clock().toISOString(),
|
|
56
|
+
offset: clock.offset()
|
|
57
|
+
}).send(res);
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
];
|
|
61
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import segmentJson from '../json/segmentJson.js';
|
|
3
|
+
import timelineOperator from '../timelineOperator.js';
|
|
4
|
+
import { errorResponse, jsonResponse, readBody, route, sendRouteError } from '@yarkivaev/simple-server';
|
|
5
|
+
|
|
6
|
+
async function handleOperatorWrite(gate, parsed, write) {
|
|
7
|
+
const audit = await gate.resolve(parsed);
|
|
8
|
+
await write(audit);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function respondToRequest(gate, timeline, requestId, req, res) {
|
|
12
|
+
const raw = await readBody(req);
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
let response;
|
|
15
|
+
await handleOperatorWrite(gate, parsed, async (audit) => {
|
|
16
|
+
response = await timeline.respond(requestId, parsed, audit);
|
|
17
|
+
});
|
|
18
|
+
if (!response) {
|
|
19
|
+
errorResponse('NOT_FOUND', `Request '${requestId}' not found`, 404).send(res);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
jsonResponse({ id: requestId, status: 'resolved' }).send(res);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Timeline REST routes for segments and label requests.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} basePath - base URL path
|
|
29
|
+
* @param {object} plant - plant domain object
|
|
30
|
+
* @param {object} [operatorOptions] - provider, requireOperator, defaultUser
|
|
31
|
+
* @returns {array} route objects
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* timelineRoute('/api/v1', plant, { provider, requireOperator: true, defaultUser: 'hmi-kiosk' });
|
|
35
|
+
*/
|
|
36
|
+
export default function timelineRoute(basePath, plant, operatorOptions) {
|
|
37
|
+
const gate = timelineOperator(operatorOptions);
|
|
38
|
+
return [
|
|
39
|
+
route('GET', `${basePath}/machines/:machineId/segments`, async (req, res, params, query) => {
|
|
40
|
+
const result = machineInPlant(plant, params.machineId);
|
|
41
|
+
if (!result) {
|
|
42
|
+
jsonResponse({ items: [] }).send(res);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const options = {};
|
|
46
|
+
if (query.from) {
|
|
47
|
+
options.from = query.from;
|
|
48
|
+
}
|
|
49
|
+
if (query.to) {
|
|
50
|
+
options.to = query.to;
|
|
51
|
+
}
|
|
52
|
+
const rows = await result.machine.timeline.list(options);
|
|
53
|
+
jsonResponse({ items: rows.map(segmentJson) }).send(res);
|
|
54
|
+
}),
|
|
55
|
+
route('PATCH', `${basePath}/machines/:machineId/segments`, async (req, res, params) => {
|
|
56
|
+
const result = machineInPlant(plant, params.machineId);
|
|
57
|
+
if (!result) {
|
|
58
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readBody(req);
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
await handleOperatorWrite(gate, parsed, async (audit) => {
|
|
65
|
+
await result.machine.timeline.retag(new Date(parsed.start), parsed.tags, parsed.properties, audit);
|
|
66
|
+
});
|
|
67
|
+
jsonResponse({ status: 'updated' }).send(res);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (gate.sendError(res, err)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
sendRouteError(res, err);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
route('GET', `${basePath}/machines/:machineId/requests`, async (req, res, params) => {
|
|
76
|
+
const result = machineInPlant(plant, params.machineId);
|
|
77
|
+
if (!result) {
|
|
78
|
+
jsonResponse({ items: [] }).send(res);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const rows = await result.machine.timeline.pending();
|
|
82
|
+
const items = rows.map(({ id, name, start_time: startTime, end_time: endTime, duration, options }) => {
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
segment: {
|
|
86
|
+
name,
|
|
87
|
+
start: startTime.toISOString(),
|
|
88
|
+
end: endTime.toISOString(),
|
|
89
|
+
duration
|
|
90
|
+
},
|
|
91
|
+
options
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
jsonResponse({ items }).send(res);
|
|
95
|
+
}),
|
|
96
|
+
route('POST', `${basePath}/machines/:machineId/requests/:requestId/respond`, async (req, res, params) => {
|
|
97
|
+
const result = machineInPlant(plant, params.machineId);
|
|
98
|
+
if (!result) {
|
|
99
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await respondToRequest(gate, result.machine.timeline, params.requestId, req, res);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (gate.sendError(res, err)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
sendRouteError(res, err);
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
];
|
|
112
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { route, sseResponse } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Alert stream route factory.
|
|
5
|
+
* Creates SSE route for /machines/:machineId/alerts/stream.
|
|
6
|
+
* Streams alert_created and alert_updated events for the specified machine.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - base URL path
|
|
9
|
+
* @param {object} plant - plant domain object from scada package
|
|
10
|
+
* @param {function} clock - time provider
|
|
11
|
+
* @returns {array} array of route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const routes = alertStream('/api/v1', plant, clock);
|
|
15
|
+
*/
|
|
16
|
+
export default function alertStream(basePath, plant, clock) {
|
|
17
|
+
function find(machineId) {
|
|
18
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
19
|
+
const machine = shop.machines.get()[machineId];
|
|
20
|
+
if (machine) {
|
|
21
|
+
return { machine, shop };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return [
|
|
27
|
+
route(
|
|
28
|
+
'GET',
|
|
29
|
+
`${basePath}/machines/:machineId/alerts/stream`,
|
|
30
|
+
(req, res, params) => {
|
|
31
|
+
const sse = sseResponse(res, clock);
|
|
32
|
+
sse.heartbeat();
|
|
33
|
+
const result = find(params.machineId);
|
|
34
|
+
if (!result) {
|
|
35
|
+
sse.close();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { machine, shop } = result;
|
|
39
|
+
const subscription = shop.alerts.stream((event) => {
|
|
40
|
+
if (event.alert.object === machine.name()) {
|
|
41
|
+
if (event.type === 'created') {
|
|
42
|
+
sse.emit('alert_created', {
|
|
43
|
+
id: event.alert.id,
|
|
44
|
+
message: event.alert.message,
|
|
45
|
+
timestamp: event.alert.timestamp.toISOString(),
|
|
46
|
+
object: event.alert.object,
|
|
47
|
+
acknowledged: event.alert.acknowledged,
|
|
48
|
+
name: event.alert.name
|
|
49
|
+
});
|
|
50
|
+
} else if (event.type === 'acknowledged') {
|
|
51
|
+
sse.emit('alert_updated', {
|
|
52
|
+
id: event.alert.id,
|
|
53
|
+
acknowledged: true
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const heartbeat = setInterval(() => {
|
|
59
|
+
sse.heartbeat();
|
|
60
|
+
}, 30000);
|
|
61
|
+
req.on('close', () => {
|
|
62
|
+
clearInterval(heartbeat);
|
|
63
|
+
subscription.cancel();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { route, sseResponse } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Heartbeat stream route factory.
|
|
5
|
+
* Creates SSE route for /heartbeat/stream that sends periodic heartbeat events.
|
|
6
|
+
* Used by frontend watchdog to detect connection loss.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - base URL path
|
|
9
|
+
* @param {function} clock - time provider
|
|
10
|
+
* @param {number} period - interval between heartbeats in milliseconds (default: 5000)
|
|
11
|
+
* @returns {array} array of route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const routes = heartbeatStream('/api/v1', () => new Date(), 1000);
|
|
15
|
+
*/
|
|
16
|
+
export default function heartbeatStream(basePath, clock, period) {
|
|
17
|
+
const ms = period || 5000;
|
|
18
|
+
return [
|
|
19
|
+
route(
|
|
20
|
+
'GET',
|
|
21
|
+
`${basePath}/heartbeat/stream`,
|
|
22
|
+
(req, res) => {
|
|
23
|
+
const sse = sseResponse(res, clock);
|
|
24
|
+
sse.heartbeat();
|
|
25
|
+
const interval = setInterval(() => {
|
|
26
|
+
sse.heartbeat();
|
|
27
|
+
}, ms);
|
|
28
|
+
req.on('close', () => {
|
|
29
|
+
clearInterval(interval);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
];
|
|
34
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import { route, sseResponse, timeExpression } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Measurement stream route factory.
|
|
6
|
+
* Creates SSE route for /machines/:machineId/measurements/stream.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - base URL path
|
|
9
|
+
* @param {object} plant - plant domain object from scada package
|
|
10
|
+
* @param {function} clock - time provider
|
|
11
|
+
* @returns {array} array of route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const routes = measurementStream('/api/v1', plant, clock);
|
|
15
|
+
*/
|
|
16
|
+
const RETAIN_MS = 10000;
|
|
17
|
+
|
|
18
|
+
export default function measurementStream(basePath, plant, clock) {
|
|
19
|
+
function beginning() {
|
|
20
|
+
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
21
|
+
}
|
|
22
|
+
function find(id) {
|
|
23
|
+
const result = machineInPlant(plant, id);
|
|
24
|
+
if (result) {
|
|
25
|
+
return result.machine;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function subscribe(machine, keys, since, step, sse) {
|
|
30
|
+
const subscriptions = [];
|
|
31
|
+
keys.forEach((key) => {
|
|
32
|
+
if (machine.sensors[key]) {
|
|
33
|
+
const subscription = machine.sensors[key].stream(since, step, (item) => {
|
|
34
|
+
sse.emit('measurement', {
|
|
35
|
+
key,
|
|
36
|
+
timestamp: item.timestamp.toISOString(),
|
|
37
|
+
value: item.value
|
|
38
|
+
});
|
|
39
|
+
}, clock);
|
|
40
|
+
subscriptions.push(subscription);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return subscriptions;
|
|
44
|
+
}
|
|
45
|
+
async function retain(machine, keys, sse) {
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
if (machine.sensors[key]) {
|
|
48
|
+
// eslint-disable-next-line no-await-in-loop
|
|
49
|
+
const reading = await machine.sensors[key].current();
|
|
50
|
+
if (reading.found && clock().getTime() - reading.timestamp.getTime() < RETAIN_MS) {
|
|
51
|
+
sse.emit('measurement', {
|
|
52
|
+
key,
|
|
53
|
+
timestamp: reading.timestamp.toISOString(),
|
|
54
|
+
value: reading.value
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
route(
|
|
62
|
+
'GET',
|
|
63
|
+
`${basePath}/machines/:machineId/measurements/stream`,
|
|
64
|
+
async (req, res, params, query) => {
|
|
65
|
+
const sse = sseResponse(res, clock);
|
|
66
|
+
sse.heartbeat();
|
|
67
|
+
const machine = find(params.machineId);
|
|
68
|
+
if (!machine) {
|
|
69
|
+
sse.close();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const keys = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
|
|
73
|
+
const sinceExpr = query.since || 'now';
|
|
74
|
+
const since = timeExpression(sinceExpr, clock, beginning).resolve();
|
|
75
|
+
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
76
|
+
const subscriptions = subscribe(machine, keys, since, step, sse);
|
|
77
|
+
const heartbeat = setInterval(() => {
|
|
78
|
+
sse.heartbeat();
|
|
79
|
+
}, 30000);
|
|
80
|
+
req.on('close', () => {
|
|
81
|
+
clearInterval(heartbeat);
|
|
82
|
+
subscriptions.forEach((subscription) => {
|
|
83
|
+
subscription.cancel();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
await retain(machine, keys, sse);
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
];
|
|
90
|
+
}
|