@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.
- package/README.md +196 -33
- package/db/migrations/C0001__central_drop_metrics.sql +1 -0
- package/db/migrations/C0002__central_revoke_delete.sql +12 -0
- package/db/migrations/C0003__central_operators.sql +7 -0
- package/db/migrations/C0004__central_operations_grants.sql +2 -0
- package/db/migrations/C0005__central_operators_grants.sql +1 -0
- package/db/migrations/C0006__central_operators_registration.sql +16 -0
- package/db/migrations/E0001__edge_metrics.sql +8 -0
- package/db/migrations/E0002__edge_retention_delete.sql +8 -0
- package/db/migrations/E0003__edge_operations_grants.sql +2 -0
- package/db/migrations/V0001__baseline.sql +25 -0
- package/db/migrations/V0002__sink_extension_tables.sql +6 -0
- package/db/migrations/V0003__operations.sql +10 -0
- package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
- package/db/migrations/V0005__user_decisions_operator.sql +2 -0
- package/index.js +75 -46
- package/package.json +28 -9
- package/src/application/bindSilentStreams.js +20 -0
- package/src/application/edgeOperatorCatalog.js +45 -0
- package/src/application/export/exportJob.js +118 -0
- package/src/application/export/exportQuery.js +32 -0
- package/src/application/export/exportSink.js +25 -0
- package/src/application/export/exportStream.js +33 -0
- package/src/application/machineInPlant.js +20 -0
- package/src/application/metricsPlant.js +35 -0
- package/src/application/plantApi.js +49 -0
- package/src/application/plantOperations.js +19 -0
- package/src/application/plantServer.js +107 -0
- package/src/application/shopWithTimeline.js +112 -0
- package/src/application/siteOperatorCatalog.js +79 -0
- package/src/application/siteServer.js +191 -0
- package/src/application/supervisorSink.js +97 -0
- package/src/application/timelineOperatorFromEnv.js +19 -0
- package/src/bin/supervisor-sink.js +28 -0
- package/src/{alert.js → domain/alerting/alert.js} +2 -2
- package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
- package/src/domain/alerting/ingest.js +18 -0
- package/src/domain/ingest/ingestCursor.js +26 -0
- package/src/domain/operation/operation.js +23 -0
- package/src/domain/operation/operations.js +81 -0
- package/src/domain/operator/operator.js +23 -0
- package/src/domain/plant/machine.js +32 -0
- package/src/domain/plant/plant.js +20 -0
- package/src/domain/plant/shop.js +24 -0
- package/src/domain/segment/dispatch.js +31 -0
- package/src/domain/segment/normalize.js +31 -0
- package/src/domain/segment/silenceBudget.js +16 -0
- package/src/{initialized.js → domain/shared/initialized.js} +3 -4
- package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
- package/src/domain/timeline/timeline.js +25 -0
- package/src/infrastructure/catalog/tag-catalog.json +27 -0
- package/src/infrastructure/catalog/tagCatalog.js +65 -0
- package/src/infrastructure/client/index.js +20 -0
- package/src/infrastructure/client/machineClient.js +159 -0
- package/src/infrastructure/client/machineOperationsClient.js +159 -0
- package/src/infrastructure/client/scadaClient.js +67 -0
- package/src/infrastructure/client/sseConnection.js +42 -0
- package/src/infrastructure/http/edge/edgeApi.js +54 -0
- package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
- package/src/infrastructure/http/edge/metricsSensor.js +15 -0
- package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
- package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
- package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
- package/src/infrastructure/http/edge/stateAccess.js +13 -0
- package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
- package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
- package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
- package/src/infrastructure/http/plant/json/operationJson.js +28 -0
- package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
- package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
- package/src/infrastructure/http/plant/operationAudit.js +56 -0
- package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
- package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
- package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
- package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
- package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
- package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
- package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
- package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
- package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
- package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
- package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
- package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
- package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
- package/src/infrastructure/http/plant/timelineOperator.js +87 -0
- package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
- package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
- package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
- package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
- package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
- package/src/infrastructure/ingest/cooldown.js +24 -0
- package/src/infrastructure/ingest/db/migrate.js +78 -0
- package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
- package/src/infrastructure/ingest/db/runRetention.js +58 -0
- package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
- package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
- package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
- package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
- package/src/infrastructure/ingest/processingErrorLog.js +25 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
- package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
- package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
- package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
- package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
- package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
- package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
- package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
- package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
- package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
- package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
- package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
- package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
- package/src/infrastructure/messaging/stomp/timeline.js +21 -0
- package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
- package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
- package/src/infrastructure/operators/centralOperators.js +64 -0
- package/src/infrastructure/operators/edgeOperators.js +39 -0
- package/src/infrastructure/operators/operatorById.js +33 -0
- package/src/infrastructure/operators/operatorExtras.js +41 -0
- package/src/infrastructure/operators/operators.js +32 -0
- package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
- package/src/infrastructure/operators/operatorsSync.js +57 -0
- package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
- package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
- package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
- package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
- package/src/infrastructure/persistence/memory/alerts.js +21 -0
- package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
- package/src/infrastructure/persistence/memory/metrics.js +69 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
- package/src/infrastructure/persistence/memory/operations.js +87 -0
- package/src/infrastructure/persistence/memory/segments.js +83 -0
- package/src/infrastructure/persistence/memory/timeline.js +56 -0
- package/src/infrastructure/persistence/metricsSensor.js +112 -0
- package/src/infrastructure/persistence/pg/alerts.js +25 -0
- package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
- package/src/infrastructure/persistence/pg/metrics.js +73 -0
- package/src/infrastructure/persistence/pg/operations.js +95 -0
- package/src/infrastructure/persistence/pg/operators.js +118 -0
- package/src/infrastructure/persistence/pg/segments.js +48 -0
- package/src/infrastructure/persistence/pg/timeline.js +53 -0
- package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
- package/src/infrastructure/persistence/postgresPool.js +24 -0
- package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
- package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
- package/src/infrastructure/sync/operationCodec.js +75 -0
- package/src/infrastructure/sync/operationSyncIngest.js +82 -0
- package/src/infrastructure/sync/operationSyncSink.js +40 -0
- package/src/activeMelting.js +0 -54
- package/src/completedMelting.js +0 -42
- package/src/event.js +0 -24
- package/src/events.js +0 -51
- package/src/interval.js +0 -18
- package/src/machineChronology.js +0 -79
- package/src/meltingChronology.js +0 -37
- package/src/meltingMachine.js +0 -53
- package/src/meltingRuleEngine.js +0 -37
- package/src/meltingShop.js +0 -32
- package/src/meltings.js +0 -97
- package/src/monitoredMeltingMachine.js +0 -33
- package/src/plant.js +0 -23
- package/src/postgresSensor.js +0 -105
- package/src/requests.js +0 -44
- package/src/rule.js +0 -33
- package/src/rules.js +0 -23
- package/src/scyllaSensor.js +0 -54
- package/src/segments.js +0 -64
- package/src/sqliteSensor.js +0 -106
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import operatorExtras, { operatorIdentityKeys } from '../../../operators/operatorExtras.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps an operator domain record to REST JSON shape.
|
|
5
|
+
* Plant-owned attributes beyond identity are passed through unchanged.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} row - operator with id, cardUid, firstName, lastName, displayName
|
|
8
|
+
* @returns {object} JSON-serializable operator
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* operatorJson({ id: 1, cardUid: 'dev-card-001', firstName: 'Ivan', lastName: 'Petrov', displayName: 'Ivan Petrov' });
|
|
12
|
+
*/
|
|
13
|
+
export default function operatorJson(row) {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
cardUid: row.cardUid,
|
|
17
|
+
firstName: row.firstName,
|
|
18
|
+
lastName: row.lastName,
|
|
19
|
+
displayName: row.displayName,
|
|
20
|
+
...operatorExtras(row, operatorIdentityKeys())
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a timeline segment row to REST JSON shape.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} row - segment with start_time, end_time, duration
|
|
5
|
+
* @returns {object} JSON-serializable segment
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* segmentJson({ name: 'on', start_time: new Date(), end_time: new Date(), duration: 60 });
|
|
9
|
+
*/
|
|
10
|
+
export default function segmentJson(row) {
|
|
11
|
+
const mapped = {
|
|
12
|
+
name: row.name,
|
|
13
|
+
start: row.start_time.toISOString(),
|
|
14
|
+
end: row.duration === 0 ? new Date().toISOString() : row.end_time.toISOString(),
|
|
15
|
+
duration: row.duration
|
|
16
|
+
};
|
|
17
|
+
if (row.options) {
|
|
18
|
+
mapped.options = row.options;
|
|
19
|
+
}
|
|
20
|
+
if (row.tags) {
|
|
21
|
+
mapped.tags = row.tags;
|
|
22
|
+
}
|
|
23
|
+
if (row.properties) {
|
|
24
|
+
mapped.properties = row.properties;
|
|
25
|
+
}
|
|
26
|
+
return mapped;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stamps operator audit into an operation payload and builds decision rows.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* const next = stampPayload(payload, audit);
|
|
6
|
+
* const row = decisionRow(machine, item, audit, 'create');
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Copies payload and adds operator display fields from audit.
|
|
11
|
+
*
|
|
12
|
+
* @param {*} payload - original operation payload
|
|
13
|
+
* @param {object} audit - { id, displayName, decidedAt }
|
|
14
|
+
* @returns {object} stamped payload object
|
|
15
|
+
*/
|
|
16
|
+
export function stampPayload(payload, audit) {
|
|
17
|
+
const base = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
18
|
+
? { ...payload }
|
|
19
|
+
: { value: payload };
|
|
20
|
+
base.operator = audit.displayName;
|
|
21
|
+
base.decided_at = audit.decidedAt.toISOString();
|
|
22
|
+
if (audit.id !== undefined && audit.id !== null) {
|
|
23
|
+
base.operator_id = audit.id;
|
|
24
|
+
}
|
|
25
|
+
return base;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds a user_decisions insert row for an operation write.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} machine - machine id
|
|
32
|
+
* @param {object} item - operation with key, kind, occurred_at, payload
|
|
33
|
+
* @param {object} audit - resolved operator audit
|
|
34
|
+
* @param {string} verb - create | update | delete
|
|
35
|
+
* @returns {object} insert row for userDecisions.insert
|
|
36
|
+
*/
|
|
37
|
+
export function decisionRow(machine, item, audit, verb) {
|
|
38
|
+
return {
|
|
39
|
+
machine,
|
|
40
|
+
startTime: item.occurred_at instanceof Date
|
|
41
|
+
? item.occurred_at
|
|
42
|
+
: new Date(item.occurred_at),
|
|
43
|
+
username: audit.displayName,
|
|
44
|
+
operatorId: audit.id,
|
|
45
|
+
decidedAt: audit.decidedAt,
|
|
46
|
+
payload: {
|
|
47
|
+
kind: 'operation_op',
|
|
48
|
+
verb,
|
|
49
|
+
key: item.key,
|
|
50
|
+
operation_kind: item.kind,
|
|
51
|
+
occurred_at: item.occurred_at instanceof Date
|
|
52
|
+
? item.occurred_at.toISOString()
|
|
53
|
+
: new Date(item.occurred_at).toISOString()
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { errorResponse, jsonResponse, pagination, route } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Alert routes factory.
|
|
5
|
+
* Creates routes for GET and PATCH /machines/:machineId/alerts.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - base URL path
|
|
8
|
+
* @param {object} plant - plant domain object from scada package
|
|
9
|
+
* @returns {array} array of route objects
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const routes = alertRoute('/api/v1', plant);
|
|
13
|
+
*/
|
|
14
|
+
export default function alertRoute(basePath, plant) {
|
|
15
|
+
function find(id) {
|
|
16
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
17
|
+
const machine = shop.machines.get()[id];
|
|
18
|
+
if (machine) {
|
|
19
|
+
return machine;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function findAlert(alertId) {
|
|
25
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
26
|
+
for (const machine of Object.values(shop.machines.get())) {
|
|
27
|
+
const alert = machine.alerts().find((a) => {
|
|
28
|
+
return a.id === alertId;
|
|
29
|
+
});
|
|
30
|
+
if (alert) {
|
|
31
|
+
return alert;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return [
|
|
38
|
+
route(
|
|
39
|
+
'GET',
|
|
40
|
+
`${basePath}/machines/:machineId/alerts`,
|
|
41
|
+
(req, res, params, query) => {
|
|
42
|
+
const machine = find(params.machineId);
|
|
43
|
+
if (!machine) {
|
|
44
|
+
jsonResponse({ items: [], page: 1, size: 10, total: 0 }).send(res);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const page = query.page ? parseInt(query.page, 10) : 1;
|
|
48
|
+
const size = query.size ? parseInt(query.size, 10) : 10;
|
|
49
|
+
let alerts = machine.alerts();
|
|
50
|
+
if (query.acknowledged === 'true') {
|
|
51
|
+
alerts = alerts.filter((a) => {
|
|
52
|
+
return a.acknowledged === true;
|
|
53
|
+
});
|
|
54
|
+
} else if (query.acknowledged === 'false') {
|
|
55
|
+
alerts = alerts.filter((a) => {
|
|
56
|
+
return a.acknowledged === false;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const mapped = alerts.map((a) => {
|
|
60
|
+
return { id: a.id, message: a.message, timestamp: a.timestamp.toISOString(), object: a.object, acknowledged: a.acknowledged, name: a.name };
|
|
61
|
+
});
|
|
62
|
+
const paginated = pagination(page, size, mapped).result();
|
|
63
|
+
jsonResponse({ items: paginated.items, page: paginated.page, size: paginated.size, total: paginated.total }).send(res);
|
|
64
|
+
}
|
|
65
|
+
),
|
|
66
|
+
route(
|
|
67
|
+
'PATCH',
|
|
68
|
+
`${basePath}/machines/:machineId/alerts/:alertId`,
|
|
69
|
+
(req, res, params) => {
|
|
70
|
+
let body = '';
|
|
71
|
+
req.on('data', (chunk) => {
|
|
72
|
+
body += chunk;
|
|
73
|
+
});
|
|
74
|
+
req.on('end', () => {
|
|
75
|
+
const changes = JSON.parse(body);
|
|
76
|
+
const alert = findAlert(params.alertId);
|
|
77
|
+
if (!alert) {
|
|
78
|
+
errorResponse(
|
|
79
|
+
'NOT_FOUND',
|
|
80
|
+
`Alert '${params.alertId}' not found`,
|
|
81
|
+
404
|
|
82
|
+
).send(res);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (changes.acknowledged === true && !alert.acknowledged) {
|
|
86
|
+
alert.acknowledge();
|
|
87
|
+
}
|
|
88
|
+
jsonResponse({ id: alert.id, message: alert.message, timestamp: alert.timestamp.toISOString(), object: alert.object, acknowledged: true, name: alert.name }).send(res);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
];
|
|
93
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
2
|
+
import tagCatalog from '../../../catalog/tagCatalog.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tag catalog HTTP routes for HMI label/cascade lookup.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - base URL path
|
|
8
|
+
* @param {object} [catalog] - optional preloaded catalog
|
|
9
|
+
* @returns {array} route objects
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* catalogRoute('/api/v1');
|
|
13
|
+
*/
|
|
14
|
+
export default function catalogRoute(basePath, catalog) {
|
|
15
|
+
const loaded = catalog || tagCatalog();
|
|
16
|
+
return [
|
|
17
|
+
route('GET', `${basePath}/tag-catalog`, (req, res) => {
|
|
18
|
+
jsonResponse({ items: loaded.entries() }).send(res);
|
|
19
|
+
})
|
|
20
|
+
];
|
|
21
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import decisionJson from '../json/decisionJson.js';
|
|
2
|
+
import httpOperations from '../../../messaging/ownership/httpOperations.js';
|
|
3
|
+
import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
|
|
4
|
+
|
|
5
|
+
function edgePort(owners, machineId) {
|
|
6
|
+
if (!owners || typeof owners.resolve !== 'function') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const owner = owners.resolve(machineId);
|
|
10
|
+
if (!owner || owner.kind !== 'edge') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return httpOperations(owner, machineId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Segment and operation user_decisions history routes.
|
|
18
|
+
*
|
|
19
|
+
* Operation chronology for edge-owned machines is proxied to the owner API.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} basePath - base URL path
|
|
22
|
+
* @param {object} catalog - user decisions port with list and listByKey
|
|
23
|
+
* @param {object} [owners] - machineOwners registry
|
|
24
|
+
* @returns {array} route objects
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* decisionRoute('/api/v1', userDecisionsFromPg(pool), owners);
|
|
28
|
+
*/
|
|
29
|
+
export default function decisionRoute(basePath, catalog, owners) {
|
|
30
|
+
return [
|
|
31
|
+
route('GET', `${basePath}/machines/:machineId/segments/:start/decisions`, async (req, res, params) => {
|
|
32
|
+
const start = new Date(decodeURIComponent(params.start));
|
|
33
|
+
if (Number.isNaN(start.getTime())) {
|
|
34
|
+
errorResponse('BAD_REQUEST', `Invalid segment start '${params.start}'`, 400).send(res);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const rows = await catalog.list(decodeURIComponent(params.machineId), start);
|
|
38
|
+
jsonResponse({ items: rows.map(decisionJson) }).send(res);
|
|
39
|
+
}),
|
|
40
|
+
route('GET', `${basePath}/machines/:machineId/operations/:key/decisions`, async (req, res, params) => {
|
|
41
|
+
const machineId = decodeURIComponent(params.machineId);
|
|
42
|
+
const key = decodeURIComponent(params.key);
|
|
43
|
+
if (!key) {
|
|
44
|
+
errorResponse('BAD_REQUEST', 'operation key is required', 400).send(res);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const port = edgePort(owners, machineId);
|
|
48
|
+
if (port) {
|
|
49
|
+
jsonResponse({ items: await port.decisions(key) }).send(res);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof catalog.listByKey !== 'function') {
|
|
53
|
+
jsonResponse({ items: [] }).send(res);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const rows = await catalog.listByKey(machineId, key);
|
|
57
|
+
jsonResponse({ items: rows.map(decisionJson) }).send(res);
|
|
58
|
+
})
|
|
59
|
+
];
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import { errorResponse, jsonResponse, route } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Machine list and info routes.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - base URL path
|
|
8
|
+
* @param {object} plant - plant domain object
|
|
9
|
+
* @returns {array} route objects
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* machineRoute('/api/v1', plant);
|
|
13
|
+
*/
|
|
14
|
+
export default function machineRoute(basePath, plant) {
|
|
15
|
+
function all() {
|
|
16
|
+
return Object.values(plant.shops.get()).flatMap((area) => {
|
|
17
|
+
return Object.values(area.machines.get()).map((item) => {
|
|
18
|
+
return { id: item.name(), name: item.name() };
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return [
|
|
23
|
+
route('GET', `${basePath}/machines`, (req, res) => {
|
|
24
|
+
jsonResponse({ items: all() }).send(res);
|
|
25
|
+
}),
|
|
26
|
+
route('GET', `${basePath}/machines/:machineId`, (req, res, params) => {
|
|
27
|
+
const result = machineInPlant(plant, params.machineId);
|
|
28
|
+
if (!result) {
|
|
29
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
jsonResponse({ id: result.machine.name(), name: result.machine.name() }).send(res);
|
|
33
|
+
})
|
|
34
|
+
];
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Measurement routes factory.
|
|
6
|
+
* Creates route for GET /machines/:machineId/measurements.
|
|
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 = measurementRoute('/api/v1', plant, clock);
|
|
15
|
+
*/
|
|
16
|
+
export default function measurementRoute(basePath, plant, clock) {
|
|
17
|
+
function beginning() {
|
|
18
|
+
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
19
|
+
}
|
|
20
|
+
function find(id) {
|
|
21
|
+
const result = machineInPlant(plant, id);
|
|
22
|
+
if (result) {
|
|
23
|
+
return result.machine;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
route(
|
|
29
|
+
'GET',
|
|
30
|
+
`${basePath}/machines/:machineId/measurements`,
|
|
31
|
+
async (req, res, params, query) => {
|
|
32
|
+
const machine = find(params.machineId);
|
|
33
|
+
if (!machine) {
|
|
34
|
+
jsonResponse({ items: [] }).send(res);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
|
|
38
|
+
const keys = requested.filter((key) => {return machine.sensors[key]});
|
|
39
|
+
const fromExpr = query.from || 'now-1M';
|
|
40
|
+
const toExpr = query.to || 'now';
|
|
41
|
+
const from = timeExpression(fromExpr, clock, beginning).resolve();
|
|
42
|
+
const to = timeExpression(toExpr, clock, beginning).resolve();
|
|
43
|
+
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
44
|
+
const range = { start: from, end: to };
|
|
45
|
+
const promises = keys.map(async (key) => {
|
|
46
|
+
const sensor = machine.sensors[key];
|
|
47
|
+
const measurements = await sensor.measurements(range, step);
|
|
48
|
+
const unit = measurements.length > 0 ? measurements[0].unit : '';
|
|
49
|
+
const values = measurements.map((row) => {return {
|
|
50
|
+
timestamp: row.timestamp.toISOString(),
|
|
51
|
+
value: row.value
|
|
52
|
+
}});
|
|
53
|
+
return { key, name: sensor.name(), unit, values };
|
|
54
|
+
});
|
|
55
|
+
const items = await Promise.all(promises);
|
|
56
|
+
jsonResponse({ items }).send(res);
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
];
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function reject(code, message, status) {
|
|
4
|
+
const err = new Error(message);
|
|
5
|
+
err.routeCode = code;
|
|
6
|
+
err.routeStatus = status;
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds a create draft from a parsed POST body.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} machineId - machine id
|
|
14
|
+
* @param {object} parsed - JSON body
|
|
15
|
+
* @returns {object} operation draft
|
|
16
|
+
*/
|
|
17
|
+
export function draftFromBody(machineId, parsed) {
|
|
18
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
19
|
+
throw reject('BAD_REQUEST', 'operation body must be a JSON object', 400);
|
|
20
|
+
}
|
|
21
|
+
if (typeof parsed.kind !== 'string' || parsed.kind.length === 0) {
|
|
22
|
+
throw reject('BAD_REQUEST', 'kind is required', 400);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.payload === undefined) {
|
|
25
|
+
throw reject('BAD_REQUEST', 'payload is required', 400);
|
|
26
|
+
}
|
|
27
|
+
const occurred = parsed.occurred_at === undefined ? new Date() : new Date(parsed.occurred_at);
|
|
28
|
+
if (Number.isNaN(occurred.getTime())) {
|
|
29
|
+
throw reject('BAD_REQUEST', 'occurred_at must be a valid timestamp', 400);
|
|
30
|
+
}
|
|
31
|
+
const key = parsed.key === undefined || parsed.key === null
|
|
32
|
+
? `${parsed.kind}:${machineId}:${randomUUID()}`
|
|
33
|
+
: parsed.key;
|
|
34
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
35
|
+
throw reject('BAD_REQUEST', 'key must be a non-empty string', 400);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
machine: machineId,
|
|
39
|
+
kind: parsed.kind,
|
|
40
|
+
key,
|
|
41
|
+
occurred_at: occurred,
|
|
42
|
+
payload: parsed.payload
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds an update draft from existing row and parsed PUT body.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} machineId - machine id
|
|
50
|
+
* @param {string} key - operation key
|
|
51
|
+
* @param {object} existing - current row
|
|
52
|
+
* @param {object} parsed - JSON body
|
|
53
|
+
* @returns {object} operation draft
|
|
54
|
+
*/
|
|
55
|
+
export function draftFromUpdate(machineId, key, existing, parsed) {
|
|
56
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
57
|
+
throw reject('BAD_REQUEST', 'operation body must be a JSON object', 400);
|
|
58
|
+
}
|
|
59
|
+
if (parsed.payload === undefined) {
|
|
60
|
+
throw reject('BAD_REQUEST', 'payload is required', 400);
|
|
61
|
+
}
|
|
62
|
+
const kind = parsed.kind === undefined ? existing.kind : parsed.kind;
|
|
63
|
+
if (typeof kind !== 'string' || kind.length === 0) {
|
|
64
|
+
throw reject('BAD_REQUEST', 'kind must be a non-empty string', 400);
|
|
65
|
+
}
|
|
66
|
+
const occurred = parsed.occurred_at === undefined
|
|
67
|
+
? new Date(existing.occurred_at)
|
|
68
|
+
: new Date(parsed.occurred_at);
|
|
69
|
+
if (Number.isNaN(occurred.getTime())) {
|
|
70
|
+
throw reject('BAD_REQUEST', 'occurred_at must be a valid timestamp', 400);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
machine: machineId,
|
|
74
|
+
kind,
|
|
75
|
+
key,
|
|
76
|
+
occurred_at: occurred,
|
|
77
|
+
payload: parsed.payload
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import operationJson from '../json/operationJson.js';
|
|
3
|
+
import timelineOperator from '../timelineOperator.js';
|
|
4
|
+
import operationWrites from './operationWrites.js';
|
|
5
|
+
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
6
|
+
|
|
7
|
+
function parseRange(query) {
|
|
8
|
+
const range = {};
|
|
9
|
+
if (query.from) {
|
|
10
|
+
range.from = new Date(query.from);
|
|
11
|
+
}
|
|
12
|
+
if (query.to) {
|
|
13
|
+
range.to = new Date(query.to);
|
|
14
|
+
}
|
|
15
|
+
return range;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveKinds(query) {
|
|
19
|
+
if (query.kinds) {
|
|
20
|
+
return query.kinds.split(',').map((token) => {
|
|
21
|
+
return token.trim();
|
|
22
|
+
}).filter((token) => {
|
|
23
|
+
return token.length > 0;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (query.kind) {
|
|
27
|
+
return [query.kind];
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Operations REST routes for machine-scoped reads and writes.
|
|
34
|
+
*
|
|
35
|
+
* Writes resolve operator via timelineOperator and stamp payload.operator.
|
|
36
|
+
* Edge-owned machines proxy create/update/delete to the owning plant API
|
|
37
|
+
* (no local upsert or decision insert). Optional owners registry mirrors timeline.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} basePath - base URL path
|
|
40
|
+
* @param {object} plant - plant domain object
|
|
41
|
+
* @param {object} [operatorOptions] - timelineOperator options
|
|
42
|
+
* @param {object} [decisions] - userDecisions port with insert
|
|
43
|
+
* @param {object} [owners] - machineOwners registry
|
|
44
|
+
* @returns {array} route objects
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* operationRoute('/api/v1', plant, timelineOperatorOpts, decisions, owners);
|
|
48
|
+
*/
|
|
49
|
+
export default function operationRoute(basePath, plant, operatorOptions, decisions, owners) {
|
|
50
|
+
const writes = operationWrites({
|
|
51
|
+
plant,
|
|
52
|
+
gate: timelineOperator(operatorOptions),
|
|
53
|
+
decisions,
|
|
54
|
+
owners
|
|
55
|
+
});
|
|
56
|
+
return [
|
|
57
|
+
route('GET', `${basePath}/machines/:machineId/operations`, async (req, res, params, query) => {
|
|
58
|
+
const result = machineInPlant(plant, params.machineId);
|
|
59
|
+
if (!result || !plant.operations) {
|
|
60
|
+
jsonResponse({ items: [] }).send(res);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const rows = await plant.operations.listForMachine(
|
|
64
|
+
params.machineId,
|
|
65
|
+
resolveKinds(query),
|
|
66
|
+
parseRange(query)
|
|
67
|
+
);
|
|
68
|
+
jsonResponse({ items: rows.map(operationJson) }).send(res);
|
|
69
|
+
}),
|
|
70
|
+
route('POST', `${basePath}/machines/:machineId/operations`, async (req, res, params) => {
|
|
71
|
+
await writes.writeCreate(params.machineId, req, res);
|
|
72
|
+
}),
|
|
73
|
+
route('PUT', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
|
|
74
|
+
await writes.writeUpdate(params.machineId, decodeURIComponent(params.key), req, res);
|
|
75
|
+
}),
|
|
76
|
+
route('DELETE', `${basePath}/machines/:machineId/operations/:key`, async (req, res, params) => {
|
|
77
|
+
await writes.writeDelete(params.machineId, decodeURIComponent(params.key), req, res);
|
|
78
|
+
})
|
|
79
|
+
];
|
|
80
|
+
}
|