@yarkivaev/scada 2.3.85 → 2.3.87

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Parent path of a node, or an empty string at the root.
3
+ *
4
+ * @param {string} path - namespace path
5
+ * @returns {string} parent path
6
+ *
7
+ * @example
8
+ * parentOf('site/shop');
9
+ */
10
+ function parentOf(path) {
11
+ const cut = path.lastIndexOf('/');
12
+ if (cut < 0) {
13
+ return '';
14
+ }
15
+ return path.slice(0, cut);
16
+ }
17
+
18
+ /**
19
+ * Topology resource body without source topics.
20
+ *
21
+ * @param {object} tree - namespaceTree
22
+ * @returns {Array<object>} nodes
23
+ *
24
+ * @example
25
+ * topology(tree);
26
+ */
27
+ function topology(tree) {
28
+ return tree.list().map((node) => {
29
+ return {
30
+ kind: node.kind,
31
+ liveness: node.silenceSeconds,
32
+ parent: parentOf(node.path),
33
+ path: node.path,
34
+ title: node.title
35
+ };
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Binding resource body: path, source topic, and unit.
41
+ *
42
+ * @param {object} tree - namespaceTree
43
+ * @returns {Array<object>} bindings
44
+ *
45
+ * @example
46
+ * bindings(tree);
47
+ */
48
+ function bindings(tree) {
49
+ return tree.list().filter((node) => {
50
+ return typeof node.source === 'string';
51
+ }).map((node) => {
52
+ return {
53
+ path: node.path,
54
+ topic: node.source,
55
+ unit: typeof node.unit === 'string' ? node.unit : ''
56
+ };
57
+ });
58
+ }
59
+
60
+ /**
61
+ * JSON resource contents for one URI.
62
+ *
63
+ * @param {string} uri - resource URI
64
+ * @param {object} payload - JSON body
65
+ * @returns {object} read result
66
+ *
67
+ * @example
68
+ * body('plant://topology', []);
69
+ */
70
+ function body(uri, payload) {
71
+ return {
72
+ contents: [{
73
+ mimeType: 'application/json',
74
+ text: JSON.stringify(payload),
75
+ uri
76
+ }]
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Registers plant topology and source bindings.
82
+ *
83
+ * Topology has paths, kinds, titles, and liveness. Bindings map a path to a
84
+ * topic and a unit. Gateway topics are not part of topology.
85
+ *
86
+ * @param {object} server - McpServer
87
+ * @param {object} tree - namespaceTree
88
+ * @returns {void}
89
+ *
90
+ * @example
91
+ * registerResources(server, tree);
92
+ */
93
+ export default function registerResources(server, tree) {
94
+ server.registerResource('topology', 'plant://topology', {
95
+ description: 'Paths, kinds, titles, and liveness',
96
+ mimeType: 'application/json'
97
+ }, () => {
98
+ return body('plant://topology', topology(tree));
99
+ });
100
+ server.registerResource('bindings', 'plant://bindings', {
101
+ description: 'Source topic and unit for each bound path',
102
+ mimeType: 'application/json'
103
+ }, () => {
104
+ return body('plant://bindings', bindings(tree));
105
+ });
106
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Caps a row list at the analytics response limit.
3
+ *
4
+ * @param {Array} rows - port rows
5
+ * @returns {{rows: Array, truncated: boolean}} capped rows
6
+ *
7
+ * @example
8
+ * capRows([{ id: 1 }]);
9
+ */
10
+ export function capRows(rows) {
11
+ const list = Array.isArray(rows) ? rows : [];
12
+ return {
13
+ rows: list.slice(0, 200),
14
+ truncated: list.length > 200
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Wraps a payload as MCP text content.
20
+ *
21
+ * @param {object} payload - JSON payload
22
+ * @returns {object} tool result
23
+ *
24
+ * @example
25
+ * toolText({ rows: [] });
26
+ */
27
+ export function toolText(payload) {
28
+ return {
29
+ content: [{
30
+ text: JSON.stringify(payload),
31
+ type: 'text'
32
+ }]
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Reads a point list from an array or a points field.
38
+ *
39
+ * @param {Array|object} raw - port payload
40
+ * @returns {Array} points
41
+ *
42
+ * @example
43
+ * readPoints({ points: [1] });
44
+ */
45
+ function readPoints(raw) {
46
+ if (Array.isArray(raw)) {
47
+ return raw;
48
+ }
49
+ if (raw && Array.isArray(raw.points)) {
50
+ return raw.points;
51
+ }
52
+ return [];
53
+ }
54
+
55
+ /**
56
+ * Reads the step the port actually used.
57
+ *
58
+ * @param {Array|object} raw - port payload
59
+ * @param {number} step - requested step seconds
60
+ * @returns {number} step seconds
61
+ *
62
+ * @example
63
+ * readStep({ points: [], step: 30 }, 15);
64
+ */
65
+ function readStep(raw, step) {
66
+ if (raw && typeof raw.step === 'number') {
67
+ return raw.step;
68
+ }
69
+ return step;
70
+ }
71
+
72
+ /**
73
+ * Keeps a truncated flag supplied by the port.
74
+ *
75
+ * @param {Array|object} raw - port payload
76
+ * @returns {boolean} whether the port already truncated
77
+ *
78
+ * @example
79
+ * flagged({ truncated: true });
80
+ */
81
+ function flagged(raw) {
82
+ if (raw && raw.truncated === true) {
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * Caps measurement points at 200 and reports the step.
90
+ *
91
+ * @param {Array|object} raw - port payload
92
+ * @param {number} step - requested step seconds
93
+ * @returns {object} points, step, truncated
94
+ *
95
+ * @example
96
+ * capMeasurements([{ v: 1 }], 15);
97
+ */
98
+ export function capMeasurements(raw, step) {
99
+ const points = readPoints(raw);
100
+ const truncated = points.length > 200 || flagged(raw);
101
+ return {
102
+ points: points.slice(0, 200),
103
+ step: readStep(raw, step),
104
+ truncated
105
+ };
106
+ }
@@ -1,7 +1,25 @@
1
1
  import plant from '../domain/plant/plant.js';
2
2
  import initialized from '../domain/shared/initialized.js';
3
+ import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
3
4
  import postgresPool from '../infrastructure/persistence/postgresPool.js';
4
5
 
6
+ /**
7
+ * Plant extras: injected ports, or pgAlerts when a pool exists.
8
+ *
9
+ * @param {object} options - optional operations and alerts
10
+ * @param {object|undefined} pool - pg pool
11
+ * @returns {object} plant options
12
+ *
13
+ * @example
14
+ * extras({ operations }, pool);
15
+ */
16
+ function extras(options, pool) {
17
+ return {
18
+ operations: options.operations,
19
+ alerts: options.alerts || (pool ? pgAlerts(pool) : undefined)
20
+ };
21
+ }
22
+
5
23
  /**
6
24
  * Generic plant factory wiring sensors and a shop factory over a connection.
7
25
  *
@@ -32,5 +50,5 @@ export default async function metricsPlant(machines, options) {
32
50
  segments: options.segments,
33
51
  shopName: options.shopName
34
52
  });
35
- return plant(initialized({ [shop.name()]: shop }, Object.values));
53
+ return plant(initialized({ [shop.name()]: shop }, Object.values), extras(options, pool));
36
54
  }
@@ -73,6 +73,13 @@ function writePort(name, decisions, owners) {
73
73
  }, owners)(name);
74
74
  }
75
75
 
76
+ function demandWrite(name, decisions, owners) {
77
+ if (!decisions) {
78
+ throw new Error(`Timeline write requires userDecisions for ${name}`);
79
+ }
80
+ return writePort(name, decisions, owners);
81
+ }
82
+
76
83
  function ownsLocal(owners, name) {
77
84
  if (!owners) {
78
85
  return true;
@@ -153,23 +160,23 @@ async function answer(scope, requestId, body, audit) {
153
160
  }
154
161
 
155
162
  /**
156
- * Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
163
+ * Builds a machine timeline from PostgreSQL when a pool exists.
157
164
  *
158
- * Optional owners registry routes retag/respond to an edge HTTP plant API.
165
+ * Reads do not need userDecisions. retag and respond throw without it.
166
+ * Optional owners registry routes those writes to an edge HTTP plant API.
159
167
  *
160
168
  * @param {string} name - machine id
161
- * @param {object} options - pool, userDecisions, optional owners
169
+ * @param {object} options - pool, optional userDecisions and owners
162
170
  * @returns {object} timeline port with list, rowAt, pending, stream, retag, respond, bus
163
171
  *
164
172
  * @example
165
- * const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
173
+ * const tl = shopWithTimeline('machine1', { pool });
166
174
  */
167
175
  export default function shopWithTimeline(name, options) {
168
176
  const { pool, userDecisions: decisions, owners, segments } = options;
169
- if (pool && decisions) {
177
+ if (pool) {
170
178
  const bus = pubsub();
171
179
  const read = pgTimeline(pool, name);
172
- const write = writePort(name, decisions, owners);
173
180
  const port = timeline(read, bus);
174
181
  return {
175
182
  list: port.list,
@@ -179,6 +186,7 @@ export default function shopWithTimeline(name, options) {
179
186
  stream: port.stream,
180
187
  bus,
181
188
  async retag(start, tags, properties, audit) {
189
+ const write = demandWrite(name, decisions, owners);
182
190
  if (ownsLocal(owners, name)) {
183
191
  await confirm(pool, {
184
192
  machine: name,
@@ -192,6 +200,7 @@ export default function shopWithTimeline(name, options) {
192
200
  bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
193
201
  },
194
202
  respond(requestId, body, audit) {
203
+ const write = demandWrite(name, decisions, owners);
195
204
  return answer({ pool, owners, name, segments, write, bus }, requestId, body, audit);
196
205
  }
197
206
  };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Reads a non-empty path.
3
+ *
4
+ * @param {object} node - candidate node
5
+ * @returns {string} path
6
+ *
7
+ * @example
8
+ * pathOf({ path: 'site/unit' });
9
+ */
10
+ function pathOf(node) {
11
+ if (typeof node.path === 'string' && node.path.length > 0) {
12
+ return node.path;
13
+ }
14
+ throw new Error('Namespace path is empty');
15
+ }
16
+
17
+ /**
18
+ * Requires a non-empty opaque kind.
19
+ *
20
+ * @param {object} node - candidate node
21
+ * @returns {string} kind
22
+ *
23
+ * @example
24
+ * kindOf({ kind: 'metric', path: 'site/metric' });
25
+ */
26
+ function kindOf(node) {
27
+ if (typeof node.kind === 'string' && node.kind.length > 0) {
28
+ return node.kind;
29
+ }
30
+ throw new Error(`Namespace kind is missing for ${pathOf(node)}`);
31
+ }
32
+
33
+ /**
34
+ * Requires a non-negative silence threshold in seconds.
35
+ *
36
+ * @param {object} node - candidate node
37
+ * @returns {number} silence seconds
38
+ *
39
+ * @example
40
+ * silenceOf({ path: 'site', silenceSeconds: 60 });
41
+ */
42
+ function silenceOf(node) {
43
+ const value = node.silenceSeconds;
44
+ if (typeof value === 'number' && value >= 0) {
45
+ return value;
46
+ }
47
+ throw new Error(`Namespace silence is missing for ${pathOf(node)}`);
48
+ }
49
+
50
+ /**
51
+ * Uses an explicit title or the last path segment.
52
+ *
53
+ * @param {object} node - candidate node
54
+ * @param {string} path - unique path
55
+ * @returns {string} display title
56
+ *
57
+ * @example
58
+ * titled({ title: 'Unit' }, 'site/unit');
59
+ */
60
+ function titled(node, path) {
61
+ if (typeof node.title === 'string' && node.title.length > 0) {
62
+ return node.title;
63
+ }
64
+ const parts = path.split('/');
65
+ return parts[parts.length - 1];
66
+ }
67
+
68
+ /**
69
+ * Copies one node into a frozen record.
70
+ *
71
+ * @param {object} node - candidate node
72
+ * @param {string} path - unique path
73
+ * @returns {object} frozen node
74
+ *
75
+ * @example
76
+ * freezeNode({ kind: 'site', path: 'site', silenceSeconds: 30 }, 'site');
77
+ */
78
+ function freezeNode(node, path) {
79
+ const frozen = {
80
+ kind: kindOf(node),
81
+ path,
82
+ silenceSeconds: silenceOf(node),
83
+ title: titled(node, path)
84
+ };
85
+ if (typeof node.source === 'string') {
86
+ frozen.source = node.source;
87
+ }
88
+ if (typeof node.unit === 'string') {
89
+ frozen.unit = node.unit;
90
+ }
91
+ return Object.freeze(frozen);
92
+ }
93
+
94
+ /**
95
+ * Builds a path map and rejects duplicates.
96
+ *
97
+ * @param {Array<object>} nodes - candidate nodes
98
+ * @returns {{ list: Array<object>, map: Map<string, object> }} index
99
+ *
100
+ * @example
101
+ * indexNodes([{ kind: 'site', path: 'site', silenceSeconds: 30 }]);
102
+ */
103
+ function indexNodes(nodes) {
104
+ const map = new Map();
105
+ for (const node of nodes) {
106
+ const path = pathOf(node);
107
+ if (map.has(path)) {
108
+ throw new Error(`Namespace path is duplicated: ${path}`);
109
+ }
110
+ map.set(path, freezeNode(node, path));
111
+ }
112
+ return { list: [...map.values()], map };
113
+ }
114
+
115
+ /**
116
+ * Returns the node for a path or throws when it is absent.
117
+ *
118
+ * @param {Map<string, object>} map - path index
119
+ * @param {string} path - requested path
120
+ * @returns {object} frozen node
121
+ *
122
+ * @example
123
+ * lookup(new Map([['site', { path: 'site' }]]), 'site');
124
+ */
125
+ function lookup(map, path) {
126
+ const node = map.get(path);
127
+ if (node) {
128
+ return node;
129
+ }
130
+ throw new Error(`Namespace path is missing: ${path}`);
131
+ }
132
+
133
+ /**
134
+ * Requires an array of nodes.
135
+ *
136
+ * @param {Array<object>} nodes - candidate nodes
137
+ * @returns {Array<object>} the same array when it is an array
138
+ *
139
+ * @example
140
+ * nodesOf([{ kind: 'site', path: 'site', silenceSeconds: 30 }]);
141
+ */
142
+ function nodesOf(nodes) {
143
+ if (Array.isArray(nodes)) {
144
+ return nodes;
145
+ }
146
+ throw new Error('Namespace nodes are missing');
147
+ }
148
+
149
+ /**
150
+ * Indexes plant nodes by a unique path for analytics reads.
151
+ *
152
+ * A node is an opaque kind, a silence threshold in seconds, and an optional
153
+ * source topic. Paths are the address. Gateway topics stay on the node and
154
+ * are not required.
155
+ *
156
+ * @param {Array<object>} nodes - path, kind, silenceSeconds, optional source, title, unit
157
+ * @returns {object} list, resolve, and below
158
+ *
159
+ * @example
160
+ * const tree = namespaceTree([
161
+ * { path: 'site/shop/unit', kind: 'unit', silenceSeconds: 60 }
162
+ * ]);
163
+ * tree.resolve('site/shop/unit').kind;
164
+ */
165
+ export default function namespaceTree(nodes) {
166
+ const index = indexNodes(nodesOf(nodes));
167
+ return Object.freeze({
168
+ below(path) {
169
+ const node = lookup(index.map, path);
170
+ const prefix = `${node.path}/`;
171
+ return index.list.filter((item) => {
172
+ return item.path.startsWith(prefix);
173
+ });
174
+ },
175
+ list() {
176
+ return index.list.slice();
177
+ },
178
+ resolve(path) {
179
+ return lookup(index.map, path);
180
+ }
181
+ });
182
+ }
@@ -3,7 +3,7 @@
3
3
  * Provides initialization for all contained shops.
4
4
  *
5
5
  * @param {object} shops - initialized list of shops
6
- * @param {object} [options] - operations, topology
6
+ * @param {object} [options] - operations, alerts, topology
7
7
  * @returns {object} plant with shops property and init method
8
8
  *
9
9
  * @example
@@ -14,6 +14,7 @@ export default function plant(shops, options = {}) {
14
14
  return {
15
15
  shops,
16
16
  operations: options.operations,
17
+ alerts: options.alerts,
17
18
  topology: options.topology,
18
19
  init() {
19
20
  shops.init();