@yarkivaev/scada 2.3.63 → 2.3.64
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
CHANGED
|
@@ -71,7 +71,7 @@ PostgreSQL ← STOMP alerts (via plantServer)
|
|
|
71
71
|
plantApi → REST + SSE → frontend / @yarkivaev/scada/client
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
`GET /api/v1/topology` returns `{ plant, nodes, links }` from `plant.topology()` or a shop/machine/sensor walk. `GET /infra` serves a generic SVG of that graph. Optional node `inspect: { host, port }` is reached same-origin at `/infra/inspect/:id` (CDP `Host` is loopback).
|
|
74
|
+
`GET /api/v1/topology` returns `{ plant, nodes, links }` from `plant.topology()` or a shop/machine/sensor walk, then paints `status` from live `sensor.current()` freshness (`ok` / `degraded` / `down`). Nodes without a matching sensor stay `unknown`. `GET /infra` serves a generic SVG of that graph. Optional node `inspect: { host, port }` is reached same-origin at `/infra/inspect/:id` (CDP `Host` is loopback).
|
|
75
75
|
|
|
76
76
|
**Supervisor-sink** (write path):
|
|
77
77
|
|
|
@@ -91,7 +91,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
91
91
|
|--------|---------|
|
|
92
92
|
| `plant`, `shop`, `machine` | Domain hierarchy |
|
|
93
93
|
| `timeline`, `alerts`, `alert`, `acknowledgedAlert` | Timeline and alerting |
|
|
94
|
-
| `plantApi`, `plantServer`, `siteServer`, `topologyFromPlant` | HTTP composition and topology graph |
|
|
94
|
+
| `plantApi`, `plantServer`, `siteServer`, `topologyFromPlant`, `topologyHealth` | HTTP composition and topology graph |
|
|
95
95
|
|
|
96
96
|
| `exportQuery`, `exportStream`, `exportSink`, `exportJob` | Generic export ports over plantApi / `@yarkivaev/scada/client` |
|
|
97
97
|
| `siteSync`, `siteSyncSites`, `siteSyncBind` | Pull kinds from an allowlisted remote plantApi into local persistence |
|
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { default as pubsub } from './src/domain/shared/pubsub.js';
|
|
|
15
15
|
|
|
16
16
|
export { default as plantApi } from './src/application/plantApi.js';
|
|
17
17
|
export { default as topologyFromPlant } from './src/application/topologyFromPlant.js';
|
|
18
|
+
export { default as topologyHealth } from './src/application/topologyHealth.js';
|
|
18
19
|
export { default as tagCatalog } from './src/infrastructure/catalog/tagCatalog.js';
|
|
19
20
|
export { default as machineInPlant } from './src/application/machineInPlant.js';
|
|
20
21
|
export { default as shopWithTimeline } from './src/application/shopWithTimeline.js';
|
package/package.json
CHANGED
|
@@ -41,7 +41,7 @@ export default function plantApi(basePath, plant, config) {
|
|
|
41
41
|
const decorate = opts.decorateTimeline || pass;
|
|
42
42
|
const routeList = [
|
|
43
43
|
...catalogRoute(basePath, opts.tagCatalog),
|
|
44
|
-
...topologyRoute(basePath, plant),
|
|
44
|
+
...topologyRoute(basePath, plant, time),
|
|
45
45
|
...machineRoute(basePath, plant),
|
|
46
46
|
...stateRoute(basePath, plant),
|
|
47
47
|
...measurementStream(basePath, plant, time),
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paints topology node status from live sensor current() readings.
|
|
3
|
+
* Does not read kube or Prometheus. Unmatched nodes keep their existing status.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} graph - topology contract { plant, nodes, links }
|
|
6
|
+
* @param {object} plant - plant with shops().get() machines and sensors
|
|
7
|
+
* @param {function} [clock] - () => Date
|
|
8
|
+
* @returns {Promise<object>} graph with status overlays
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* await topologyHealth(graph, plant, () => new Date());
|
|
12
|
+
*/
|
|
13
|
+
function age(reading, now) {
|
|
14
|
+
if (!reading || reading.found !== true || !reading.timestamp) {
|
|
15
|
+
return Number.POSITIVE_INFINITY;
|
|
16
|
+
}
|
|
17
|
+
return now.getTime() - new Date(reading.timestamp).getTime();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function grade(ms) {
|
|
21
|
+
if (!Number.isFinite(ms)) {
|
|
22
|
+
return 'down';
|
|
23
|
+
}
|
|
24
|
+
if (ms <= 300000) {
|
|
25
|
+
return 'ok';
|
|
26
|
+
}
|
|
27
|
+
if (ms <= 1800000) {
|
|
28
|
+
return 'degraded';
|
|
29
|
+
}
|
|
30
|
+
return 'down';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rollup(states) {
|
|
34
|
+
if (states.length === 0) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (states.every((item) => {
|
|
38
|
+
return item === 'ok';
|
|
39
|
+
})) {
|
|
40
|
+
return 'ok';
|
|
41
|
+
}
|
|
42
|
+
if (states.every((item) => {
|
|
43
|
+
return item === 'down';
|
|
44
|
+
})) {
|
|
45
|
+
return 'down';
|
|
46
|
+
}
|
|
47
|
+
return 'degraded';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function sample(id, item, now) {
|
|
51
|
+
try {
|
|
52
|
+
return [id, grade(age(await item.current(), now))];
|
|
53
|
+
} catch {
|
|
54
|
+
return [id, 'down'];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function probeMachine(machineId, item, now) {
|
|
59
|
+
const keys = Object.keys(item.sensors || {});
|
|
60
|
+
const pairs = await Promise.all(keys.map((key) => {
|
|
61
|
+
return sample(`${machineId}-${key}`, item.sensors[key], now);
|
|
62
|
+
}));
|
|
63
|
+
const status = rollup(pairs.map((pair) => {
|
|
64
|
+
return pair[1];
|
|
65
|
+
}));
|
|
66
|
+
if (status) {
|
|
67
|
+
pairs.push([machineId, status]);
|
|
68
|
+
}
|
|
69
|
+
return pairs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function machineMarks(pairs, machineIds) {
|
|
73
|
+
return machineIds.map((machineId) => {
|
|
74
|
+
const hit = pairs.find((pair) => {
|
|
75
|
+
return pair[0] === machineId;
|
|
76
|
+
});
|
|
77
|
+
return hit ? hit[1] : undefined;
|
|
78
|
+
}).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function probeShop(shopId, area, now) {
|
|
82
|
+
const machines = area.machines.get();
|
|
83
|
+
const chunks = await Promise.all(Object.entries(machines).map(([machineId, item]) => {
|
|
84
|
+
return probeMachine(machineId, item, now);
|
|
85
|
+
}));
|
|
86
|
+
const pairs = chunks.flat();
|
|
87
|
+
const status = rollup(machineMarks(pairs, Object.keys(machines)));
|
|
88
|
+
if (status) {
|
|
89
|
+
pairs.push([shopId, status]);
|
|
90
|
+
}
|
|
91
|
+
return pairs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusOf(node, marks) {
|
|
95
|
+
if (marks.has(node.id)) {
|
|
96
|
+
return marks.get(node.id);
|
|
97
|
+
}
|
|
98
|
+
if (node.kind === 'collector' && node.parent && marks.has(node.parent)) {
|
|
99
|
+
return marks.get(node.parent);
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function paint(graph, marks) {
|
|
105
|
+
return {
|
|
106
|
+
plant: graph.plant,
|
|
107
|
+
links: graph.links,
|
|
108
|
+
nodes: graph.nodes.map((node) => {
|
|
109
|
+
const status = statusOf(node, marks);
|
|
110
|
+
if (!status) {
|
|
111
|
+
return node;
|
|
112
|
+
}
|
|
113
|
+
return { ...node, status };
|
|
114
|
+
})
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default async function topologyHealth(graph, plant, clock) {
|
|
119
|
+
if (!plant.shops || typeof plant.shops.get !== 'function') {
|
|
120
|
+
return graph;
|
|
121
|
+
}
|
|
122
|
+
const now = clock ? clock() : new Date();
|
|
123
|
+
const shops = plant.shops.get();
|
|
124
|
+
const chunks = await Promise.all(Object.entries(shops).map(([shopId, area]) => {
|
|
125
|
+
return probeShop(shopId, area, now);
|
|
126
|
+
}));
|
|
127
|
+
return paint(graph, new Map(chunks.flat()));
|
|
128
|
+
}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
2
2
|
import topologyFromPlant from '../../../../application/topologyFromPlant.js';
|
|
3
|
+
import topologyHealth from '../../../../application/topologyHealth.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Plant topology graph route.
|
|
6
7
|
*
|
|
7
8
|
* @param {string} basePath - API base path
|
|
8
9
|
* @param {object} plant - plant domain object
|
|
10
|
+
* @param {function} [clock] - () => Date for sensor freshness
|
|
9
11
|
* @returns {array} route objects
|
|
10
12
|
*
|
|
11
13
|
* @example
|
|
12
14
|
* topologyRoute('/api/v1', plant);
|
|
13
15
|
*/
|
|
14
|
-
export default function topologyRoute(basePath, plant) {
|
|
16
|
+
export default function topologyRoute(basePath, plant, clock) {
|
|
15
17
|
return [
|
|
16
|
-
route('GET', `${basePath}/topology`, (req, res) => {
|
|
17
|
-
|
|
18
|
+
route('GET', `${basePath}/topology`, async (req, res) => {
|
|
19
|
+
const graph = await topologyHealth(topologyFromPlant(plant), plant, clock);
|
|
20
|
+
jsonResponse(graph).send(res);
|
|
18
21
|
})
|
|
19
22
|
];
|
|
20
23
|
}
|