@yarkivaev/scada 2.3.54 → 2.3.55

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.54",
3
+ "version": "2.3.55",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,7 @@ export default async function metricsPlant(machines, options) {
29
29
  pool,
30
30
  alerts: options.alerts,
31
31
  userDecisions: options.userDecisions,
32
+ segments: options.segments,
32
33
  shopName: options.shopName
33
34
  });
34
35
  return plant(initialized({ [shop.name()]: shop }, Object.values));
@@ -6,6 +6,7 @@ import postgresPool from '../infrastructure/persistence/postgresPool.js';
6
6
  import stompAlerts from '../infrastructure/messaging/stomp/alerts/stompAlerts.js';
7
7
  import stompTimelineSegments from '../infrastructure/messaging/stomp/stompTimelineSegments.js';
8
8
  import userDecisions from '../infrastructure/messaging/stomp/userDecisions.js';
9
+ import segmentRetags from '../infrastructure/messaging/stomp/segmentRetags.js';
9
10
  import { parseRequestTimeoutMs, virtualClock } from '@yarkivaev/simple-server';
10
11
 
11
12
  function stompCollectorFactory(stompUrl, destination, credentials) {
@@ -63,7 +64,13 @@ async function initStomp(stomp, translations, requirePool) {
63
64
  passcode: stomp.passcode,
64
65
  host: stomp.host
65
66
  });
66
- return { alerts, userDecisions: decisions };
67
+ const segments = segmentRetags({
68
+ stompUrl: stomp.url,
69
+ login: stomp.login,
70
+ passcode: stomp.passcode,
71
+ host: stomp.host
72
+ });
73
+ return { alerts, userDecisions: decisions, segments };
67
74
  }
68
75
 
69
76
  /**
@@ -79,8 +86,8 @@ async function initStomp(stomp, translations, requirePool) {
79
86
  * await plantServer({ port: 3000, basePath: '/api/v1', plantFactory, extraRoutes });
80
87
  */
81
88
  export default async function plantServer(config) {
82
- const { alerts, userDecisions: decisions } = await initStomp(config.stomp, config.translations, config.requirePool);
83
- const p = await config.plantFactory({ alerts, userDecisions: decisions });
89
+ const { alerts, userDecisions: decisions, segments: retags } = await initStomp(config.stomp, config.translations, config.requirePool);
90
+ const p = await config.plantFactory({ alerts, userDecisions: decisions, segments: retags });
84
91
  const segments = wireTimelineSegments(config.stomp, p);
85
92
  const clock = virtualClock(() => {
86
93
  return new Date();
@@ -1,9 +1,11 @@
1
1
  import pubsub from '../domain/shared/pubsub.js';
2
2
  import timeline from '../domain/timeline/timeline.js';
3
3
  import pgTimeline from '../infrastructure/persistence/pg/timeline.js';
4
+ import segmentStatePg from '../infrastructure/persistence/pg/segments.js';
4
5
  import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persistence/memory/timeline.js';
5
6
  import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
6
7
  import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
8
+ import retagBody from '../infrastructure/messaging/stomp/retagBody.js';
7
9
 
8
10
  function parseStart(requestId) {
9
11
  const raw = decodeURIComponent(String(requestId));
@@ -69,6 +71,34 @@ function writePort(name, decisions, owners) {
69
71
  }, owners)(name);
70
72
  }
71
73
 
74
+ function ownsLocal(owners, name) {
75
+ if (!owners) {
76
+ return true;
77
+ }
78
+ return owners.resolve(name).kind !== 'edge';
79
+ }
80
+
81
+ async function persistTags(pool, patch) {
82
+ const state = segmentStatePg(pool);
83
+ const tagsJson = JSON.stringify(patch.tags);
84
+ const propsJson = JSON.stringify(patch.properties || {});
85
+ const count = patch.resolved
86
+ ? await state.resolveRequest(patch.machine, patch.start, tagsJson, propsJson)
87
+ : await state.retag(patch.machine, patch.start, tagsJson, propsJson);
88
+ if (!count) {
89
+ throw new RangeError(`Segment ${patch.machine} at ${patch.start.toISOString()} was not updated`);
90
+ }
91
+ return state.rowAt(patch.machine, patch.start);
92
+ }
93
+
94
+ async function confirm(pool, patch, segments) {
95
+ const row = await persistTags(pool, patch);
96
+ if (segments) {
97
+ await segments.publish(retagBody(patch.machine, row, patch.tags, patch.properties || {}));
98
+ }
99
+ return row;
100
+ }
101
+
72
102
  /**
73
103
  * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
74
104
  *
@@ -82,7 +112,7 @@ function writePort(name, decisions, owners) {
82
112
  * const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
83
113
  */
84
114
  export default function shopWithTimeline(name, options) {
85
- const { pool, userDecisions: decisions, owners } = options;
115
+ const { pool, userDecisions: decisions, owners, segments } = options;
86
116
  if (pool && decisions) {
87
117
  const bus = pubsub();
88
118
  const read = pgTimeline(pool, name);
@@ -95,11 +125,29 @@ export default function shopWithTimeline(name, options) {
95
125
  stream: port.stream,
96
126
  bus,
97
127
  async retag(start, tags, properties, audit) {
128
+ if (ownsLocal(owners, name)) {
129
+ await confirm(pool, {
130
+ machine: name,
131
+ start,
132
+ tags,
133
+ properties,
134
+ resolved: false
135
+ }, segments);
136
+ }
98
137
  await write.retag(start, tags, properties, audit);
99
138
  bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
100
139
  },
101
140
  async respond(requestId, body, audit) {
102
141
  const start = parseStart(requestId);
142
+ if (ownsLocal(owners, name)) {
143
+ await confirm(pool, {
144
+ machine: name,
145
+ start,
146
+ tags: body.tags,
147
+ properties: body.properties || {},
148
+ resolved: true
149
+ }, segments);
150
+ }
103
151
  await write.respond(start, body.tags, body.properties || {}, audit);
104
152
  bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
105
153
  return { id: requestId, ...body };
@@ -0,0 +1,37 @@
1
+ function tagList(row) {
2
+ const raw = row.tags;
3
+ if (Array.isArray(raw)) {
4
+ return raw;
5
+ }
6
+ if (typeof raw === 'string' && raw.length > 0) {
7
+ return JSON.parse(raw);
8
+ }
9
+ return [];
10
+ }
11
+
12
+ /**
13
+ * Walks newest-first timeline rows until a reset tag and returns them oldest-first.
14
+ *
15
+ * Reset ids are opaque; callers pass cycle start/stop tags.
16
+ *
17
+ * @param {Array<object>} rows - newest-first rows with tags
18
+ * @param {Array<string>} resetTags - tag ids that close the lookback
19
+ * @returns {Array<object>} chronological slice including the reset row
20
+ *
21
+ * @example
22
+ * cycleLookback(newestFirst, ['cycle-start', 'cycle-stop'])
23
+ */
24
+ export default function cycleLookback(rows, resetTags) {
25
+ const reset = new Set(resetTags);
26
+ const collected = [];
27
+ for (let i = 0; i < rows.length; i += 1) {
28
+ const row = rows[i];
29
+ collected.push(row);
30
+ if (tagList(row).some((id) => {
31
+ return reset.has(id);
32
+ })) {
33
+ break;
34
+ }
35
+ }
36
+ return collected.reverse();
37
+ }
@@ -37,6 +37,38 @@ function segmentRoute(token, checkpointState) {
37
37
  });
38
38
  }
39
39
 
40
+ function cyclePriorQuery(query) {
41
+ if (!query.machineId) {
42
+ return { error: 'machineId is required' };
43
+ }
44
+ const before = parseFrom(query.before);
45
+ if (before === null) {
46
+ return { error: 'before must be a number' };
47
+ }
48
+ const reset = toTopicList(query.reset);
49
+ if (reset.length === 0) {
50
+ return { error: 'reset is required' };
51
+ }
52
+ return { machineId: query.machineId, before, reset };
53
+ }
54
+
55
+ function cyclePriorRoute(token, checkpointState) {
56
+ return route('GET', '/v1/checkpoint/cycle-prior', async (req, res, params, query) => {
57
+ void params;
58
+ if (!hasAccess(req, token)) {
59
+ sendForbidden(res);
60
+ return;
61
+ }
62
+ const parsed = cyclePriorQuery(query);
63
+ if (parsed.error) {
64
+ errorResponse('BAD_REQUEST', parsed.error, 400).send(res);
65
+ return;
66
+ }
67
+ const items = await checkpointState.cyclePrior(parsed.machineId, parsed.before, parsed.reset);
68
+ jsonResponse({ items }).send(res);
69
+ });
70
+ }
71
+
40
72
  export default function checkpointRoutes(token, checkpointState) {
41
73
  return [
42
74
  route('GET', '/v1/checkpoint/replay-cursor', async (req, res, params, query) => {
@@ -53,6 +85,7 @@ export default function checkpointRoutes(token, checkpointState) {
53
85
  jsonResponse({ machineId: query.machineId, cursor }).send(res);
54
86
  }),
55
87
  segmentRoute(token, checkpointState),
88
+ cyclePriorRoute(token, checkpointState),
56
89
  route('GET', '/v1/checkpoint/pending-segments', async (req, res) => {
57
90
  if (!hasAccess(req, token)) {
58
91
  sendForbidden(res);
@@ -0,0 +1,39 @@
1
+ function asList(raw) {
2
+ if (Array.isArray(raw)) {
3
+ return raw;
4
+ }
5
+ if (typeof raw === 'string' && raw.length > 0) {
6
+ return JSON.parse(raw);
7
+ }
8
+ return [];
9
+ }
10
+
11
+ /**
12
+ * Builds a segments-exchange retag payload matching supervisor SegmentMessage.retag.
13
+ *
14
+ * @param {string} machine - machine id
15
+ * @param {object} row - persisted segment with start_time, end_time, duration, name, options
16
+ * @param {string[]} tags - operator tags
17
+ * @param {object} properties - operator properties
18
+ * @returns {object} STOMP JSON body
19
+ *
20
+ * @example
21
+ * retagBody('icht1', row, ['to_ladle'], {})
22
+ */
23
+ export default function retagBody(machine, row, tags, properties) {
24
+ const options = asList(row.options);
25
+ const props = properties && Object.keys(properties).length > 0 ? properties : null;
26
+ return {
27
+ type: 'retag',
28
+ status: 'completed',
29
+ machine,
30
+ name: row.name,
31
+ start: new Date(row.start_time).getTime(),
32
+ end: new Date(row.end_time).getTime(),
33
+ duration: row.duration,
34
+ tags,
35
+ options: options.length > 0 ? options : null,
36
+ properties: props,
37
+ resolved: true
38
+ };
39
+ }
@@ -0,0 +1,35 @@
1
+ import { stompSend } from '@yarkivaev/source-to-sink';
2
+
3
+ const DEFAULT_DESTINATION = '/exchange/scada.segments';
4
+
5
+ /**
6
+ * Publishes operator retag envelopes to the segments exchange.
7
+ *
8
+ * @param {object} config - publisher configuration
9
+ * @param {string} config.stompUrl - STOMP broker URL
10
+ * @param {string} [config.destination] - STOMP destination
11
+ * @param {string} [config.login] - STOMP login
12
+ * @param {string} [config.passcode] - STOMP passcode
13
+ * @param {string} [config.host] - STOMP vhost header
14
+ * @returns {object} publisher with publish(body)
15
+ *
16
+ * @example
17
+ * const retags = segmentRetags({ stompUrl });
18
+ * await retags.publish(retagBody('icht1', row, ['to_ladle'], {}));
19
+ */
20
+ export default function segmentRetags(config) {
21
+ if (!config.stompUrl) {
22
+ throw new Error('stompUrl is required for segment retag publisher');
23
+ }
24
+ const destination = config.destination || DEFAULT_DESTINATION;
25
+ const stompOptions = {
26
+ login: config.login,
27
+ passcode: config.passcode,
28
+ host: config.host
29
+ };
30
+ return {
31
+ async publish(body) {
32
+ await stompSend(config.stompUrl, destination, body, stompOptions);
33
+ }
34
+ };
35
+ }
@@ -1,3 +1,5 @@
1
+ import cycleLookback from '../../../domain/timeline/cycleLookback.js';
2
+
1
3
  function parseJsonField(raw) {
2
4
  if (!raw) {
3
5
  return null;
@@ -93,6 +95,17 @@ export default function checkpointStateMemory(store) {
93
95
  },
94
96
  segment(machineId, startEpoch) {
95
97
  return segmentAt(store, machineId, startEpoch);
98
+ },
99
+ cyclePrior(machineId, beforeEpoch, resetTags) {
100
+ const beforeMs = Number(beforeEpoch) * 1000;
101
+ const rows = store.segments.filter((row) => {
102
+ return row.machine === machineId && new Date(row.start_time).getTime() < beforeMs;
103
+ }).sort((a, b) => {
104
+ return new Date(b.start_time) - new Date(a.start_time);
105
+ });
106
+ return cycleLookback(rows, resetTags).map((row) => {
107
+ return segmentItem(row);
108
+ });
96
109
  }
97
110
  };
98
111
  }
@@ -65,19 +65,23 @@ export default function segmentStateMemory(store) {
65
65
  },
66
66
  retag(machineId, start, tagsJson, propertiesJson) {
67
67
  const row = findRow(store, machineId, start);
68
- if (row) {
69
- row.tags = tagsJson;
70
- row.properties = propertiesJson;
68
+ if (!row) {
69
+ return 0;
71
70
  }
71
+ row.tags = tagsJson;
72
+ row.properties = propertiesJson;
73
+ return 1;
72
74
  },
73
75
  resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
74
76
  const row = findRow(store, machineId, startKey);
75
- if (row) {
76
- row.tags = tagsJson;
77
- row.properties = propertiesJson;
78
- row.resolved = true;
79
- row.consumed = false;
77
+ if (!row) {
78
+ return 0;
80
79
  }
80
+ row.tags = tagsJson;
81
+ row.properties = propertiesJson;
82
+ row.resolved = true;
83
+ row.consumed = false;
84
+ return 1;
81
85
  }
82
86
  };
83
87
  }
@@ -1,4 +1,5 @@
1
1
  import segmentStatePg from './segments.js';
2
+ import cycleLookback from '../../../domain/timeline/cycleLookback.js';
2
3
 
3
4
  function parseJsonField(raw) {
4
5
  if (!raw) {
@@ -110,6 +111,19 @@ export default function checkpointStatePg(pool, metricsEnabled = true) {
110
111
  },
111
112
  segment(machineId, startEpoch) {
112
113
  return segmentAt(pool, machineId, startEpoch);
114
+ },
115
+ cyclePrior(machineId, beforeEpoch, resetTags) {
116
+ return pool.query(
117
+ `SELECT name, start_time, end_time, duration, tags, options, properties
118
+ FROM segments
119
+ WHERE machine = $1 AND start_time < $2
120
+ ORDER BY start_time DESC`,
121
+ [machineId, new Date(beforeEpoch * 1000)]
122
+ ).then((result) => {
123
+ return cycleLookback(result.rows, resetTags).map((row) => {
124
+ return segmentItem(row, machineId);
125
+ });
126
+ });
113
127
  }
114
128
  };
115
129
  }
@@ -33,16 +33,18 @@ export default function segmentStatePg(pool) {
33
33
  return result.rows;
34
34
  },
35
35
  async retag(machineId, start, tagsJson, propertiesJson) {
36
- await pool.query(
36
+ const result = await pool.query(
37
37
  'UPDATE segments SET tags = $1, properties = $2 WHERE machine = $3 AND start_time = $4',
38
38
  [tagsJson, propertiesJson, machineId, start]
39
39
  );
40
+ return result.rowCount;
40
41
  },
41
42
  async resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
42
- await pool.query(
43
+ const result = await pool.query(
43
44
  'UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE WHERE machine = $3 AND start_time = $4',
44
45
  [tagsJson, propertiesJson, machineId, startKey]
45
46
  );
47
+ return result.rowCount;
46
48
  }
47
49
  };
48
50
  }