@yarkivaev/scada 2.3.53 → 2.3.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.53",
3
+ "version": "2.3.54",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,12 +12,16 @@ import simulationRoute from '../infrastructure/http/plant/routes/simulationRoute
12
12
  import catalogRoute from '../infrastructure/http/plant/routes/catalogRoute.js';
13
13
  import { routes } from '@yarkivaev/simple-server';
14
14
 
15
+ function pass(_id, rows) {
16
+ return rows;
17
+ }
18
+
15
19
  /**
16
20
  * Composable plant HTTP API factory.
17
21
  *
18
22
  * @param {string} basePath - base URL path
19
23
  * @param {object} plant - plant domain object with operations (optional kindSources at construction)
20
- * @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat
24
+ * @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat, decorateTimeline
21
25
  * @returns {object} routes with list() and handle()
22
26
  *
23
27
  * @example
@@ -30,6 +34,7 @@ export default function plantApi(basePath, plant, config) {
30
34
  return new Date();
31
35
  });
32
36
  const extra = opts.extraRoutes || [];
37
+ const decorate = opts.decorateTimeline || pass;
33
38
  const routeList = [
34
39
  ...catalogRoute(basePath, opts.tagCatalog),
35
40
  ...machineRoute(basePath, plant),
@@ -37,8 +42,8 @@ export default function plantApi(basePath, plant, config) {
37
42
  ...measurementRoute(basePath, plant, time),
38
43
  ...alertStream(basePath, plant, time),
39
44
  ...alertRoute(basePath, plant),
40
- ...timelineRoute(basePath, plant, opts.timelineOperator),
41
- ...timelineStream(basePath, plant, time),
45
+ ...timelineRoute(basePath, plant, opts.timelineOperator, decorate),
46
+ ...timelineStream(basePath, plant, time, decorate),
42
47
  ...operationRoute(basePath, plant, opts.timelineOperator, opts.operationDecisions, opts.owners),
43
48
  ...operationStream(basePath, plant, time),
44
49
  ...heartbeatStream(basePath, time, opts.heartbeat),
@@ -72,7 +72,7 @@ async function initStomp(stomp, translations, requirePool) {
72
72
  * Multi-kind operations come from plant.operations built with kindSources
73
73
  * (e.g. plantOperations(persistence, { temp })).
74
74
  *
75
- * @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool
75
+ * @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool, decorateTimeline
76
76
  * @returns {Promise<object>} server, plant, api, segments
77
77
  *
78
78
  * @example
@@ -95,7 +95,8 @@ export default async function plantServer(config) {
95
95
  extraRoutes: extra,
96
96
  timelineOperator: config.timelineOperator,
97
97
  operationDecisions: config.operationDecisions,
98
- owners: config.owners
98
+ owners: config.owners,
99
+ decorateTimeline: config.decorateTimeline
99
100
  });
100
101
  const server = http.createServer((req, res) => {
101
102
  return api.handle(req, res);
@@ -142,7 +142,7 @@ function siteExtraRoutes(catalog, extraRoutes) {
142
142
  /**
143
143
  * Unified site process: supervisor-sink HTTP, plant API, and optional MQTT ingest.
144
144
  *
145
- * @param {object} config - port, basePath, translations, requirePool, plantFactory, extraRoutes, operatorCatalog, kindSources, streams, env
145
+ * @param {object} config - port, basePath, translations, requirePool, plantFactory, extraRoutes, operatorCatalog, kindSources, streams, env, decorateTimeline
146
146
  * @returns {Promise<object>} sink, plant, mqtt pipeline
147
147
  *
148
148
  * @example
@@ -185,7 +185,8 @@ export default async function siteServer(config) {
185
185
  extraRoutes: siteExtraRoutes(catalog, config.extraRoutes),
186
186
  timelineOperator: timelineOperatorFromEnv(catalog, env, config),
187
187
  operationDecisions: catalog.decisions,
188
- owners: config.owners
188
+ owners: config.owners,
189
+ decorateTimeline: config.decorateTimeline
189
190
  });
190
191
  return { sink, plant, mqtt, telemetry, operationSync, operatorsSync: catalog.sync };
191
192
  }
@@ -9,12 +9,16 @@ async function handleOperatorWrite(gate, parsed, write) {
9
9
  await write(audit);
10
10
  }
11
11
 
12
- async function rejectUnknownTags(timeline, parsed, machineId, res) {
12
+ async function rejectUnknownTags(timeline, parsed, machineId, res, decorate) {
13
13
  if (typeof timeline.rowAt !== 'function') {
14
14
  return false;
15
15
  }
16
16
  const row = await timeline.rowAt(new Date(parsed.start));
17
- if (!row || allowedSegmentTags(row.options, row.tags, parsed.tags)) {
17
+ if (!row) {
18
+ return false;
19
+ }
20
+ const [gated] = await decorate(machineId, [row]);
21
+ if (allowedSegmentTags(gated.options, gated.tags, parsed.tags)) {
18
22
  return false;
19
23
  }
20
24
  errorResponse('BAD_REQUEST', `Tag is not in segment options for ${machineId}`, 400).send(res);
@@ -41,12 +45,13 @@ async function respondToRequest(gate, timeline, requestId, req, res) {
41
45
  * @param {string} basePath - base URL path
42
46
  * @param {object} plant - plant domain object
43
47
  * @param {object} [operatorOptions] - provider, requireOperator, defaultUser
48
+ * @param {function} decorate - (machineId, rows) => rows, applied before JSON and PATCH gate
44
49
  * @returns {array} route objects
45
50
  *
46
51
  * @example
47
52
  * timelineRoute('/api/v1', plant, { provider, requireOperator: true, defaultUser: 'hmi-kiosk' });
48
53
  */
49
- export default function timelineRoute(basePath, plant, operatorOptions) {
54
+ export default function timelineRoute(basePath, plant, operatorOptions, decorate) {
50
55
  const gate = timelineOperator(operatorOptions);
51
56
  return [
52
57
  route('GET', `${basePath}/machines/:machineId/segments`, async (req, res, params, query) => {
@@ -62,7 +67,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
62
67
  if (query.to) {
63
68
  options.to = query.to;
64
69
  }
65
- const rows = await result.machine.timeline.list(options);
70
+ const rows = await decorate(params.machineId, await result.machine.timeline.list(options));
66
71
  jsonResponse({ items: rows.map(segmentJson) }).send(res);
67
72
  }),
68
73
  route('PATCH', `${basePath}/machines/:machineId/segments`, async (req, res, params) => {
@@ -74,7 +79,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
74
79
  try {
75
80
  const raw = await readBody(req);
76
81
  const parsed = JSON.parse(raw);
77
- if (await rejectUnknownTags(result.machine.timeline, parsed, params.machineId, res)) {
82
+ if (await rejectUnknownTags(result.machine.timeline, parsed, params.machineId, res, decorate)) {
78
83
  return;
79
84
  }
80
85
  await handleOperatorWrite(gate, parsed, async (audit) => {
@@ -94,7 +99,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
94
99
  jsonResponse({ items: [] }).send(res);
95
100
  return;
96
101
  }
97
- const rows = await result.machine.timeline.pending();
102
+ const rows = await decorate(params.machineId, await result.machine.timeline.pending());
98
103
  const items = rows.map(({ id, name, start_time: startTime, end_time: endTime, duration, options }) => {
99
104
  return {
100
105
  id,
@@ -22,18 +22,53 @@ function segmentPayload(segment) {
22
22
  return data;
23
23
  }
24
24
 
25
+ async function emitSegment(sse, event, decorate, machineId) {
26
+ if (event.type === 'created' && event.segment) {
27
+ const [row] = await decorate(machineId, [event.segment]);
28
+ sse.emit('segment_created', segmentPayload(row));
29
+ return;
30
+ }
31
+ if (event.type === 'resolved' && event.segment) {
32
+ const [row] = await decorate(machineId, [event.segment]);
33
+ sse.emit('segment_resolved', segmentPayload(row));
34
+ }
35
+ }
36
+
37
+ async function emitRequest(sse, event, decorate, machineId) {
38
+ if (event.type === 'created' && event.request) {
39
+ const [row] = await decorate(machineId, [event.request]);
40
+ const start = row.start_time || row.startTime;
41
+ const end = row.end_time || row.endTime;
42
+ sse.emit('request_created', {
43
+ id: row.id,
44
+ segment: {
45
+ name: row.name,
46
+ start: start.toISOString(),
47
+ end: end.toISOString(),
48
+ duration: row.duration
49
+ },
50
+ options: row.options
51
+ });
52
+ return;
53
+ }
54
+ if (event.type === 'resolved' && event.request) {
55
+ sse.emit('request_resolved', { id: event.request.id });
56
+ }
57
+ }
58
+
25
59
  /**
26
60
  * Timeline SSE routes for segments and label requests.
27
61
  *
28
62
  * @param {string} basePath - base URL path
29
63
  * @param {object} plant - plant domain object
30
64
  * @param {function} clock - time provider
65
+ * @param {function} decorate - (machineId, rows) => rows
31
66
  * @returns {array} route objects
32
67
  *
33
68
  * @example
34
69
  * timelineStream('/api/v1', plant, clock);
35
70
  */
36
- export default function timelineStream(basePath, plant, clock) {
71
+ export default function timelineStream(basePath, plant, clock, decorate) {
37
72
  return [
38
73
  route('GET', `${basePath}/machines/:machineId/segments/stream`, (req, res, params) => {
39
74
  const sse = sseResponse(res, clock);
@@ -44,11 +79,7 @@ export default function timelineStream(basePath, plant, clock) {
44
79
  return;
45
80
  }
46
81
  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
- }
82
+ return emitSegment(sse, event, decorate, params.machineId);
52
83
  });
53
84
  const heartbeat = setInterval(() => {
54
85
  sse.heartbeat();
@@ -67,23 +98,7 @@ export default function timelineStream(basePath, plant, clock) {
67
98
  return;
68
99
  }
69
100
  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
- }
101
+ return emitRequest(sse, event, decorate, params.machineId);
87
102
  });
88
103
  const heartbeat = setInterval(() => {
89
104
  sse.heartbeat();