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