@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,42 @@
|
|
|
1
|
+
import operationJson from '../json/operationJson.js';
|
|
2
|
+
import { route, sseResponse } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Operations SSE route for plant-wide create, update, and delete events.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - base URL path
|
|
8
|
+
* @param {object} plant - plant domain object
|
|
9
|
+
* @param {function} clock - time provider
|
|
10
|
+
* @returns {array} route objects
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* operationStream('/api/v1', plant, clock);
|
|
14
|
+
*/
|
|
15
|
+
export default function operationStream(basePath, plant, clock) {
|
|
16
|
+
return [
|
|
17
|
+
route('GET', `${basePath}/operations/stream`, (req, res) => {
|
|
18
|
+
const sse = sseResponse(res, clock);
|
|
19
|
+
sse.heartbeat();
|
|
20
|
+
if (!plant.operations) {
|
|
21
|
+
sse.close();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const subscription = plant.operations.stream((event) => {
|
|
25
|
+
if (event.type === 'created' && event.operation) {
|
|
26
|
+
sse.emit('operation_created', operationJson(event.operation));
|
|
27
|
+
} else if (event.type === 'updated' && event.operation) {
|
|
28
|
+
sse.emit('operation_updated', operationJson(event.operation));
|
|
29
|
+
} else if (event.type === 'deleted' && event.operation) {
|
|
30
|
+
sse.emit('operation_deleted', operationJson(event.operation));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const heartbeat = setInterval(() => {
|
|
34
|
+
sse.heartbeat();
|
|
35
|
+
}, 30000);
|
|
36
|
+
req.on('close', () => {
|
|
37
|
+
clearInterval(heartbeat);
|
|
38
|
+
subscription.cancel();
|
|
39
|
+
});
|
|
40
|
+
})
|
|
41
|
+
];
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import { route, sseResponse } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
function segmentPayload(segment) {
|
|
5
|
+
const start = segment.start_time || segment.startTime;
|
|
6
|
+
const end = segment.end_time || segment.endTime;
|
|
7
|
+
const data = {
|
|
8
|
+
name: segment.name,
|
|
9
|
+
start: start.toISOString(),
|
|
10
|
+
end: end.toISOString(),
|
|
11
|
+
duration: segment.duration
|
|
12
|
+
};
|
|
13
|
+
if (segment.options) {
|
|
14
|
+
data.options = segment.options;
|
|
15
|
+
}
|
|
16
|
+
if (segment.tags) {
|
|
17
|
+
data.tags = segment.tags;
|
|
18
|
+
}
|
|
19
|
+
if (segment.properties) {
|
|
20
|
+
data.properties = segment.properties;
|
|
21
|
+
}
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Timeline SSE routes for segments and label requests.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} basePath - base URL path
|
|
29
|
+
* @param {object} plant - plant domain object
|
|
30
|
+
* @param {function} clock - time provider
|
|
31
|
+
* @returns {array} route objects
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* timelineStream('/api/v1', plant, clock);
|
|
35
|
+
*/
|
|
36
|
+
export default function timelineStream(basePath, plant, clock) {
|
|
37
|
+
return [
|
|
38
|
+
route('GET', `${basePath}/machines/:machineId/segments/stream`, (req, res, params) => {
|
|
39
|
+
const sse = sseResponse(res, clock);
|
|
40
|
+
sse.heartbeat();
|
|
41
|
+
const result = machineInPlant(plant, params.machineId);
|
|
42
|
+
if (!result) {
|
|
43
|
+
sse.close();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const subscription = result.machine.timeline.stream((event) => {
|
|
47
|
+
if (event.type === 'created' && event.segment) {
|
|
48
|
+
sse.emit('segment_created', segmentPayload(event.segment));
|
|
49
|
+
} else if (event.type === 'resolved' && event.segment) {
|
|
50
|
+
sse.emit('segment_resolved', segmentPayload(event.segment));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const heartbeat = setInterval(() => {
|
|
54
|
+
sse.heartbeat();
|
|
55
|
+
}, 30000);
|
|
56
|
+
req.on('close', () => {
|
|
57
|
+
clearInterval(heartbeat);
|
|
58
|
+
subscription.cancel();
|
|
59
|
+
});
|
|
60
|
+
}),
|
|
61
|
+
route('GET', `${basePath}/machines/:machineId/requests/stream`, (req, res, params) => {
|
|
62
|
+
const sse = sseResponse(res, clock);
|
|
63
|
+
sse.heartbeat();
|
|
64
|
+
const result = machineInPlant(plant, params.machineId);
|
|
65
|
+
if (!result) {
|
|
66
|
+
sse.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const subscription = result.machine.timeline.stream((event) => {
|
|
70
|
+
if (event.type === 'created' && event.request) {
|
|
71
|
+
const reqItem = event.request;
|
|
72
|
+
const start = reqItem.start_time || reqItem.startTime;
|
|
73
|
+
const end = reqItem.end_time || reqItem.endTime;
|
|
74
|
+
sse.emit('request_created', {
|
|
75
|
+
id: reqItem.id,
|
|
76
|
+
segment: {
|
|
77
|
+
name: reqItem.name,
|
|
78
|
+
start: start.toISOString(),
|
|
79
|
+
end: end.toISOString(),
|
|
80
|
+
duration: reqItem.duration
|
|
81
|
+
},
|
|
82
|
+
options: reqItem.options
|
|
83
|
+
});
|
|
84
|
+
} else if (event.type === 'resolved' && event.request) {
|
|
85
|
+
sse.emit('request_resolved', { id: event.request.id });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const heartbeat = setInterval(() => {
|
|
89
|
+
sse.heartbeat();
|
|
90
|
+
}, 30000);
|
|
91
|
+
req.on('close', () => {
|
|
92
|
+
clearInterval(heartbeat);
|
|
93
|
+
subscription.cancel();
|
|
94
|
+
});
|
|
95
|
+
})
|
|
96
|
+
];
|
|
97
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import operatorById from '../../operators/operatorById.js';
|
|
2
|
+
import { errorResponse } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
function routeError(code, message, status) {
|
|
5
|
+
const err = new Error(message);
|
|
6
|
+
err.routeCode = code;
|
|
7
|
+
err.routeStatus = status;
|
|
8
|
+
return err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves anonymous display name from an optional client marker.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} body - parsed JSON body with optional client
|
|
15
|
+
* @param {object} cfg - timelineOperator options with anonymousUsers and defaultUser
|
|
16
|
+
* @returns {string} display name for the decision audit row
|
|
17
|
+
*/
|
|
18
|
+
function anonymousName(body, cfg) {
|
|
19
|
+
const key = body && body.client;
|
|
20
|
+
const map = cfg.anonymousUsers || {};
|
|
21
|
+
if (typeof key === 'string' && Object.hasOwn(map, key)) {
|
|
22
|
+
return map[key];
|
|
23
|
+
}
|
|
24
|
+
return cfg.defaultUser || 'hmi-kiosk';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves operator audit context for timeline write routes.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} options - provider, requireOperator, defaultUser, anonymousUsers
|
|
31
|
+
* @returns {object} gate with resolve(body) returning audit context
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const gate = timelineOperator({
|
|
35
|
+
* provider, requireOperator: true, defaultUser: 'hmi-kiosk',
|
|
36
|
+
* anonymousUsers: { hmi: 'Anonymous HMI user' }
|
|
37
|
+
* });
|
|
38
|
+
* const audit = await gate.resolve({ operatorId: 2 });
|
|
39
|
+
*/
|
|
40
|
+
export default function timelineOperator(options) {
|
|
41
|
+
const cfg = options || {};
|
|
42
|
+
const lookup = cfg.provider ? operatorById(cfg.provider) : undefined;
|
|
43
|
+
return {
|
|
44
|
+
/**
|
|
45
|
+
* Builds audit context from request body operatorId field.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} body - parsed JSON body with optional operatorId
|
|
48
|
+
* @returns {Promise<object>} audit with id, displayName, decidedAt
|
|
49
|
+
*/
|
|
50
|
+
async resolve(body) {
|
|
51
|
+
const { operatorId } = body;
|
|
52
|
+
const decidedAt = new Date();
|
|
53
|
+
if (cfg.requireOperator && (operatorId === undefined || operatorId === null)) {
|
|
54
|
+
throw routeError('FORBIDDEN', 'operator required', 403);
|
|
55
|
+
}
|
|
56
|
+
if (operatorId !== undefined && operatorId !== null) {
|
|
57
|
+
if (!lookup) {
|
|
58
|
+
throw routeError('SERVICE_UNAVAILABLE', 'operators catalog unavailable', 503);
|
|
59
|
+
}
|
|
60
|
+
const row = await lookup.resolve(operatorId);
|
|
61
|
+
if (!row) {
|
|
62
|
+
throw routeError('BAD_REQUEST', `unknown operator id ${operatorId}`, 400);
|
|
63
|
+
}
|
|
64
|
+
return { id: row.id, displayName: row.displayName, decidedAt };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
id: undefined,
|
|
68
|
+
displayName: anonymousName(body, cfg),
|
|
69
|
+
decidedAt
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
/**
|
|
73
|
+
* Sends a route error response when resolve threw a route error.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} res - HTTP response
|
|
76
|
+
* @param {Error} err - error from resolve()
|
|
77
|
+
* @returns {boolean} true when a response was sent
|
|
78
|
+
*/
|
|
79
|
+
sendError(res, err) {
|
|
80
|
+
if (err.routeCode && err.routeStatus) {
|
|
81
|
+
errorResponse(err.routeCode, err.message, err.routeStatus).send(res);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
batch,
|
|
3
|
+
circuit,
|
|
4
|
+
clickhouseSink,
|
|
5
|
+
clock,
|
|
6
|
+
lokiSource,
|
|
7
|
+
timedBatch
|
|
8
|
+
} from '@yarkivaev/source-to-sink';
|
|
9
|
+
import activityCodec from './activityTransformer.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pipeline for streaming Loki HTTP access logs to ClickHouse.
|
|
13
|
+
*
|
|
14
|
+
* Polls Loki for log entries matching the query and batches them
|
|
15
|
+
* before inserting into ClickHouse `activity.http_access` table.
|
|
16
|
+
* Uses circuit breaker for failure isolation.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* const pipeline = activityTracking(
|
|
20
|
+
* 'http://localhost:3100',
|
|
21
|
+
* 'http://localhost:8123',
|
|
22
|
+
* '{app="traefik"}',
|
|
23
|
+
* { poll: 10, size: 100, interval: 5, threshold: 5, timeout: 60 }
|
|
24
|
+
* );
|
|
25
|
+
* pipeline.start();
|
|
26
|
+
* // ... later
|
|
27
|
+
* pipeline.stop();
|
|
28
|
+
*
|
|
29
|
+
* @param {string} loki - Loki base URL
|
|
30
|
+
* @param {string} clickhouse - ClickHouse URL
|
|
31
|
+
* @param {string} query - LogQL query string
|
|
32
|
+
* @param {object} config - Pipeline configuration
|
|
33
|
+
* @param {number} config.poll - Loki polling interval in seconds
|
|
34
|
+
* @param {number} config.size - Batch size before flush
|
|
35
|
+
* @param {number} config.interval - Seconds before time-based flush
|
|
36
|
+
* @param {number} config.threshold - Circuit breaker failure threshold
|
|
37
|
+
* @param {number} config.timeout - Circuit breaker timeout in seconds
|
|
38
|
+
* @returns {object} Pipeline with start() and stop() methods
|
|
39
|
+
*/
|
|
40
|
+
export default function activityTracking(loki, clickhouse, query, config) {
|
|
41
|
+
if (typeof loki !== 'string' || loki.length === 0) {
|
|
42
|
+
throw new Error('Loki URL must be a non-empty string');
|
|
43
|
+
}
|
|
44
|
+
if (typeof clickhouse !== 'string' || clickhouse.length === 0) {
|
|
45
|
+
throw new Error('ClickHouse URL must be a non-empty string');
|
|
46
|
+
}
|
|
47
|
+
if (typeof query !== 'string' || query.length === 0) {
|
|
48
|
+
throw new Error('Query must be a non-empty string');
|
|
49
|
+
}
|
|
50
|
+
if (!config || typeof config !== 'object') {
|
|
51
|
+
throw new Error('Config must be an object');
|
|
52
|
+
}
|
|
53
|
+
const clk = clock();
|
|
54
|
+
const breaker = circuit(config.threshold, config.timeout, clk);
|
|
55
|
+
const sink = clickhouseSink(clickhouse, 'activity.http_access');
|
|
56
|
+
const collector = timedBatch(batch(sink, config.size, breaker), config.interval);
|
|
57
|
+
const transformer = activityCodec(collector);
|
|
58
|
+
const source = lokiSource(loki, query, config.poll, transformer, clk);
|
|
59
|
+
return {
|
|
60
|
+
/**
|
|
61
|
+
* Starts the pipeline.
|
|
62
|
+
*/
|
|
63
|
+
start() {
|
|
64
|
+
source.start();
|
|
65
|
+
},
|
|
66
|
+
/**
|
|
67
|
+
* Stops the pipeline.
|
|
68
|
+
*/
|
|
69
|
+
stop() {
|
|
70
|
+
source.stop();
|
|
71
|
+
collector.stop();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transformer for converting Traefik JSON log entries to activity records.
|
|
3
|
+
*
|
|
4
|
+
* Parses Traefik access log format and extracts user activity fields.
|
|
5
|
+
* Skips entries that cannot be parsed or lack required fields.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const transformer = activityTransformer(collector);
|
|
9
|
+
* transformer.accept({ ts: 123, line: '{"StartUTC":"...","RequestMethod":"GET",...}' });
|
|
10
|
+
*
|
|
11
|
+
* @param {object} collector - Collector with accept() method
|
|
12
|
+
* @returns {object} Transformer with accept() method
|
|
13
|
+
*/
|
|
14
|
+
export default function activityCodec(collector) {
|
|
15
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
16
|
+
throw new Error('Collector must have an accept() method');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
/**
|
|
20
|
+
* Accepts Loki log entry and transforms to activity record.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} entry - Log entry with ts and line
|
|
23
|
+
*/
|
|
24
|
+
accept(entry) {
|
|
25
|
+
try {
|
|
26
|
+
const log = JSON.parse(entry.line);
|
|
27
|
+
if (!log.StartUTC || !log.RequestMethod || !log.RequestPath) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
collector.accept({
|
|
31
|
+
ts: new Date(log.StartUTC).getTime(),
|
|
32
|
+
user: log['request_Remote-User'] || '',
|
|
33
|
+
email: log['request_Remote-Email'] || '',
|
|
34
|
+
method: log.RequestMethod || '',
|
|
35
|
+
path: log.RequestPath || '',
|
|
36
|
+
status: parseInt(log.DownstreamStatus, 10) || 0,
|
|
37
|
+
duration: parseFloat(log.Duration) / 1e9 || 0,
|
|
38
|
+
ip: log.ClientHost || ''
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// Skip malformed log entries
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codec for converting raw STOMP alert messages to PostgreSQL-ready records.
|
|
5
|
+
*
|
|
6
|
+
* Parses JSON payloads and extracts name, machine, severity, status,
|
|
7
|
+
* and timestamps. Translates rule names to human-readable messages
|
|
8
|
+
* using the provided translation map. Converts epoch timestamps to
|
|
9
|
+
* ISO strings for PostgreSQL TIMESTAMPTZ columns.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const codec = alertCodec(collector, { low_cosphi: 'Switch off compensation' });
|
|
13
|
+
* codec.accept({ destination: '/exchange/scada.alerts', payload: '{"name":"low_cosphi","machine":"m2","severity":"warning","status":"pending","start":1700000000}' });
|
|
14
|
+
*
|
|
15
|
+
* @param {object} collector - Collector with accept() method
|
|
16
|
+
* @param {object} translations - Map of rule names to human-readable messages
|
|
17
|
+
* @returns {object} Codec with accept() method
|
|
18
|
+
*/
|
|
19
|
+
export default function alertCodec(collector, translations) {
|
|
20
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
21
|
+
throw new Error('Collector must have an accept() method');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
/**
|
|
25
|
+
* Accepts a raw STOMP message and forwards the decoded record to the collector.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} raw - Raw message with destination and payload
|
|
28
|
+
*/
|
|
29
|
+
async accept(raw) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw.payload);
|
|
32
|
+
if (typeof parsed.name !== 'string') {
|
|
33
|
+
throw new Error('Alert missing name field');
|
|
34
|
+
}
|
|
35
|
+
if (typeof parsed.machine !== 'string') {
|
|
36
|
+
throw new Error('Alert missing machine field');
|
|
37
|
+
}
|
|
38
|
+
const timestamp = new Date(parsed.start);
|
|
39
|
+
if (isNaN(timestamp.getTime())) {
|
|
40
|
+
throw new RangeError(`Invalid epoch timestamp: ${parsed.start}`);
|
|
41
|
+
}
|
|
42
|
+
await collector.accept({
|
|
43
|
+
name: parsed.name,
|
|
44
|
+
message: translations[parsed.name] || parsed.name,
|
|
45
|
+
machine: parsed.machine,
|
|
46
|
+
severity: parsed.severity,
|
|
47
|
+
status: parsed.status,
|
|
48
|
+
timestamp: timestamp.toISOString()
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
processingErrorLog('alert_codec', error, { destination: raw.destination, payload: raw.payload });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import segmentNormalize from '../../../domain/segment/normalize.js';
|
|
2
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codec for converting raw STOMP segment messages to PostgreSQL-ready records.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} collector - Collector with accept() method
|
|
8
|
+
* @returns {object} Codec with accept() method
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const codec = segmentCodec(collector);
|
|
12
|
+
* codec.accept({ destination: '/exchange/scada.segments', payload: '{"machine":"m2","name":"on","start":1700000000,"end":1700003600,"duration":3600}' });
|
|
13
|
+
*/
|
|
14
|
+
export default function segmentCodec(collector) {
|
|
15
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
16
|
+
throw new Error('Collector must have an accept() method');
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
async accept(raw) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(raw.payload);
|
|
22
|
+
const record = segmentNormalize(parsed);
|
|
23
|
+
await collector.accept(record);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
processingErrorLog('segment_codec', error, { destination: raw.destination, payload: raw.payload });
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import processingErrorLog from '../processingErrorLog.js';
|
|
2
|
+
|
|
3
|
+
function parseStartTime(parsed) {
|
|
4
|
+
const startTime = new Date(parsed.start * 1000);
|
|
5
|
+
if (isNaN(startTime.getTime())) {
|
|
6
|
+
throw new RangeError(`Invalid epoch timestamp: ${parsed.start}`);
|
|
7
|
+
}
|
|
8
|
+
return startTime;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseDecidedAt(parsed) {
|
|
12
|
+
if (parsed.decided_at === undefined || parsed.decided_at === null) {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
const decided = new Date(parsed.decided_at * 1000);
|
|
16
|
+
if (isNaN(decided.getTime())) {
|
|
17
|
+
throw new RangeError(`Invalid decided_at timestamp: ${parsed.decided_at}`);
|
|
18
|
+
}
|
|
19
|
+
return decided.toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function decisionRecord(raw, parsed, startTime, decidedAt) {
|
|
23
|
+
const record = {
|
|
24
|
+
machine: parsed.machine,
|
|
25
|
+
startTime: startTime.toISOString(),
|
|
26
|
+
username: parsed.user,
|
|
27
|
+
payload: raw.payload,
|
|
28
|
+
decidedAt
|
|
29
|
+
};
|
|
30
|
+
if (parsed.operator_id !== undefined && parsed.operator_id !== null) {
|
|
31
|
+
record.operatorId = Number(parsed.operator_id);
|
|
32
|
+
}
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Codec for converting raw STOMP user decision messages to sink-ready records.
|
|
38
|
+
*
|
|
39
|
+
* Parses JSON payloads and extracts machine, start timestamp, user, operator_id,
|
|
40
|
+
* decided_at, and the full raw payload for audit storage.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const codec = userDecisionCodec(collector);
|
|
44
|
+
* codec.accept({ destination: '/exchange/scada.user_decisions',
|
|
45
|
+
* payload: '{"machine":"m2","start":1700000000,"user":"op1","operator_id":2}' });
|
|
46
|
+
*
|
|
47
|
+
* @param {object} collector - Collector with accept() method
|
|
48
|
+
* @returns {object} Codec with accept() method
|
|
49
|
+
*/
|
|
50
|
+
export default function userDecisionCodec(collector) {
|
|
51
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
52
|
+
throw new Error('Collector must have an accept() method');
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
/**
|
|
56
|
+
* Accepts a raw STOMP message and forwards the decoded record to the collector.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} raw - Raw message with destination and payload
|
|
59
|
+
*/
|
|
60
|
+
async accept(raw) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(raw.payload);
|
|
63
|
+
if (typeof parsed.machine !== 'string') {
|
|
64
|
+
throw new Error('Decision missing machine field');
|
|
65
|
+
}
|
|
66
|
+
if (typeof parsed.user !== 'string') {
|
|
67
|
+
throw new Error('Decision missing user field');
|
|
68
|
+
}
|
|
69
|
+
const startTime = parseStartTime(parsed);
|
|
70
|
+
const decidedAt = parseDecidedAt(parsed);
|
|
71
|
+
await collector.accept(decisionRecord(raw, parsed, startTime, decidedAt));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
processingErrorLog('decision_codec', error, { destination: raw.destination, payload: raw.payload });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decorator that rate-limits an issue callback by message.
|
|
3
|
+
* Blocks duplicate messages within the cooldown interval.
|
|
4
|
+
*
|
|
5
|
+
* @param {function} issue - callback to wrap (message, timestamp)
|
|
6
|
+
* @param {number} interval - cooldown interval in milliseconds
|
|
7
|
+
* @returns {function} wrapped callback that enforces cooldown
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const limited = cooldown(console.log, 60000);
|
|
11
|
+
* limited('alert', new Date()); // fires
|
|
12
|
+
* limited('alert', new Date()); // blocked (within 60s)
|
|
13
|
+
*/
|
|
14
|
+
export default function cooldown(issue, interval) {
|
|
15
|
+
const history = {};
|
|
16
|
+
return function limited(message, timestamp) {
|
|
17
|
+
const now = timestamp.getTime();
|
|
18
|
+
const last = history[message] || 0;
|
|
19
|
+
if (now - last >= interval) {
|
|
20
|
+
history[message] = now;
|
|
21
|
+
issue(message, timestamp);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { migrationApplies, migrationSinkRunnable } from './migrationProfile.js';
|
|
4
|
+
/* eslint-disable no-await-in-loop */
|
|
5
|
+
|
|
6
|
+
function migrationSelected(name, profile, privileged) {
|
|
7
|
+
if (!name.endsWith('.sql') || !migrationApplies(name, profile)) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const sinkRunnable = migrationSinkRunnable(name);
|
|
11
|
+
return privileged ? !sinkRunnable : sinkRunnable;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function ensureMigrationsTable(pool) {
|
|
15
|
+
await pool.query(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
17
|
+
version TEXT PRIMARY KEY,
|
|
18
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function applyMigrationFiles(pool, migrationsDir, profile, privileged) {
|
|
24
|
+
const files = (await fs.readdir(migrationsDir))
|
|
25
|
+
.filter((name) => {
|
|
26
|
+
return migrationSelected(name, profile, privileged);
|
|
27
|
+
})
|
|
28
|
+
.sort((left, right) => {
|
|
29
|
+
return left.localeCompare(right);
|
|
30
|
+
});
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const { rowCount } = await pool.query(
|
|
33
|
+
'SELECT 1 FROM schema_migrations WHERE version = $1',
|
|
34
|
+
[file]
|
|
35
|
+
);
|
|
36
|
+
if (rowCount === 0) {
|
|
37
|
+
const sql = await fs.readFile(path.join(migrationsDir, file), 'utf8');
|
|
38
|
+
await pool.query('BEGIN');
|
|
39
|
+
try {
|
|
40
|
+
await pool.query(sql);
|
|
41
|
+
await pool.query(
|
|
42
|
+
'INSERT INTO schema_migrations (version) VALUES ($1)',
|
|
43
|
+
[file]
|
|
44
|
+
);
|
|
45
|
+
await pool.query('COMMIT');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await pool.query('ROLLBACK');
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Applies SQL migrations runnable as supervisor_sink.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} pool - pg.Pool instance.
|
|
58
|
+
* @param {string} migrationsDir - absolute path to migration files.
|
|
59
|
+
* @param {string} [profile] - central or edge; filters E/C prefixed files
|
|
60
|
+
* @returns {Promise<void>}
|
|
61
|
+
*/
|
|
62
|
+
export default async function migrate(pool, migrationsDir, profile = 'edge') {
|
|
63
|
+
await ensureMigrationsTable(pool);
|
|
64
|
+
await applyMigrationFiles(pool, migrationsDir, profile, false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Applies privilege migrations that require Postgres superuser (scada).
|
|
69
|
+
*
|
|
70
|
+
* @param {object} pool - pg.Pool connected as scada or other superuser.
|
|
71
|
+
* @param {string} migrationsDir - absolute path to migration files.
|
|
72
|
+
* @param {string} [profile] - central or edge; filters E/C prefixed files
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
export async function migratePrivileged(pool, migrationsDir, profile = 'central') {
|
|
76
|
+
await ensureMigrationsTable(pool);
|
|
77
|
+
await applyMigrationFiles(pool, migrationsDir, profile, true);
|
|
78
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns whether a migration file applies to the given database profile.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} fileName - migration file name
|
|
5
|
+
* @param {string} profile - sink database profile: central or edge
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function migrationApplies(fileName, profile) {
|
|
9
|
+
if (fileName.startsWith('E')) {
|
|
10
|
+
return profile === 'edge';
|
|
11
|
+
}
|
|
12
|
+
if (fileName.startsWith('C')) {
|
|
13
|
+
return profile === 'central';
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const privilegedOnly = new Set([
|
|
19
|
+
'C0002__central_revoke_delete.sql',
|
|
20
|
+
'C0004__central_operations_grants.sql',
|
|
21
|
+
'C0005__central_operators_grants.sql',
|
|
22
|
+
'C0006__central_operators_registration.sql',
|
|
23
|
+
'E0002__edge_retention_delete.sql',
|
|
24
|
+
'E0003__edge_operations_grants.sql'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Privilege migrations run as scada only, not supervisor_sink.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} fileName - migration file name
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function migrationSinkRunnable(fileName) {
|
|
34
|
+
return !privilegedOnly.has(fileName);
|
|
35
|
+
}
|