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