@yarkivaev/scada 2.3.62 → 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 +4 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/application/plantApi.js +6 -0
- package/src/application/plantServer.js +4 -0
- package/src/application/topologyFromPlant.js +85 -0
- package/src/application/topologyHealth.js +128 -0
- package/src/domain/plant/plant.js +3 -1
- package/src/infrastructure/client/scadaClient.js +4 -1
- package/src/infrastructure/http/plant/inspect/cdpGateway.js +143 -0
- package/src/infrastructure/http/plant/inspect/inspectUpgrade.js +76 -0
- package/src/infrastructure/http/plant/routes/infraRoute.js +50 -0
- package/src/infrastructure/http/plant/routes/inspectRoute.js +110 -0
- package/src/infrastructure/http/plant/routes/topologyRoute.js +23 -0
- package/src/infrastructure/http/plant/ui/infra/app.css +154 -0
- package/src/infrastructure/http/plant/ui/infra/app.js +14 -0
- package/src/infrastructure/http/plant/ui/infra/graph.js +106 -0
- package/src/infrastructure/http/plant/ui/infra/panel.js +89 -0
- package/src/infrastructure/http/plant/ui/infra.html +21 -0
package/README.md
CHANGED
|
@@ -71,6 +71,8 @@ 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, 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
|
+
|
|
74
76
|
**Supervisor-sink** (write path):
|
|
75
77
|
|
|
76
78
|
```
|
|
@@ -89,7 +91,8 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
89
91
|
|--------|---------|
|
|
90
92
|
| `plant`, `shop`, `machine` | Domain hierarchy |
|
|
91
93
|
| `timeline`, `alerts`, `alert`, `acknowledgedAlert` | Timeline and alerting |
|
|
92
|
-
| `plantApi`, `plantServer`, `siteServer` | HTTP composition |
|
|
94
|
+
| `plantApi`, `plantServer`, `siteServer`, `topologyFromPlant`, `topologyHealth` | HTTP composition and topology graph |
|
|
95
|
+
|
|
93
96
|
| `exportQuery`, `exportStream`, `exportSink`, `exportJob` | Generic export ports over plantApi / `@yarkivaev/scada/client` |
|
|
94
97
|
| `siteSync`, `siteSyncSites`, `siteSyncBind` | Pull kinds from an allowlisted remote plantApi into local persistence |
|
|
95
98
|
| `metricsPlant`, `shopWithTimeline`, `machineInPlant` | Plant wiring helpers |
|
package/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export { default as initialized } from './src/domain/shared/initialized.js';
|
|
|
14
14
|
export { default as pubsub } from './src/domain/shared/pubsub.js';
|
|
15
15
|
|
|
16
16
|
export { default as plantApi } from './src/application/plantApi.js';
|
|
17
|
+
export { default as topologyFromPlant } from './src/application/topologyFromPlant.js';
|
|
18
|
+
export { default as topologyHealth } from './src/application/topologyHealth.js';
|
|
17
19
|
export { default as tagCatalog } from './src/infrastructure/catalog/tagCatalog.js';
|
|
18
20
|
export { default as machineInPlant } from './src/application/machineInPlant.js';
|
|
19
21
|
export { default as shopWithTimeline } from './src/application/shopWithTimeline.js';
|
package/package.json
CHANGED
|
@@ -11,6 +11,9 @@ import operationStream from '../infrastructure/http/plant/streams/operationStrea
|
|
|
11
11
|
import heartbeatStream from '../infrastructure/http/plant/streams/heartbeatStream.js';
|
|
12
12
|
import simulationRoute from '../infrastructure/http/plant/routes/simulationRoute.js';
|
|
13
13
|
import catalogRoute from '../infrastructure/http/plant/routes/catalogRoute.js';
|
|
14
|
+
import topologyRoute from '../infrastructure/http/plant/routes/topologyRoute.js';
|
|
15
|
+
import infraRoute from '../infrastructure/http/plant/routes/infraRoute.js';
|
|
16
|
+
import inspectRoute from '../infrastructure/http/plant/routes/inspectRoute.js';
|
|
14
17
|
import { routes } from '@yarkivaev/simple-server';
|
|
15
18
|
|
|
16
19
|
function pass(_id, rows) {
|
|
@@ -38,6 +41,7 @@ export default function plantApi(basePath, plant, config) {
|
|
|
38
41
|
const decorate = opts.decorateTimeline || pass;
|
|
39
42
|
const routeList = [
|
|
40
43
|
...catalogRoute(basePath, opts.tagCatalog),
|
|
44
|
+
...topologyRoute(basePath, plant, time),
|
|
41
45
|
...machineRoute(basePath, plant),
|
|
42
46
|
...stateRoute(basePath, plant),
|
|
43
47
|
...measurementStream(basePath, plant, time),
|
|
@@ -50,6 +54,8 @@ export default function plantApi(basePath, plant, config) {
|
|
|
50
54
|
...operationStream(basePath, plant, time),
|
|
51
55
|
...heartbeatStream(basePath, time, opts.heartbeat),
|
|
52
56
|
...(time.jump ? simulationRoute(basePath, time) : []),
|
|
57
|
+
...infraRoute(),
|
|
58
|
+
...inspectRoute(plant),
|
|
53
59
|
...extra
|
|
54
60
|
];
|
|
55
61
|
return routes(routeList, { requestTimeoutMs: opts.requestTimeoutMs });
|
|
@@ -8,6 +8,7 @@ import stompTimelineSegments from '../infrastructure/messaging/stomp/stompTimeli
|
|
|
8
8
|
import userDecisions from '../infrastructure/messaging/stomp/userDecisions.js';
|
|
9
9
|
import segmentRetags from '../infrastructure/messaging/stomp/segmentRetags.js';
|
|
10
10
|
import { parseRequestTimeoutMs, virtualClock } from '@yarkivaev/simple-server';
|
|
11
|
+
import inspectUpgrade from '../infrastructure/http/plant/inspect/inspectUpgrade.js';
|
|
11
12
|
|
|
12
13
|
function stompCollectorFactory(stompUrl, destination, credentials) {
|
|
13
14
|
return (collector) => {
|
|
@@ -108,6 +109,9 @@ export default async function plantServer(config) {
|
|
|
108
109
|
const server = http.createServer((req, res) => {
|
|
109
110
|
return api.handle(req, res);
|
|
110
111
|
});
|
|
112
|
+
server.on('upgrade', (req, socket, head) => {
|
|
113
|
+
inspectUpgrade(p, req, socket, head);
|
|
114
|
+
});
|
|
111
115
|
await new Promise((resolve) => {
|
|
112
116
|
server.listen(config.port, resolve);
|
|
113
117
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the plant topology JSON contract.
|
|
3
|
+
* An explicit plant.topology() function wins; otherwise the shop/machine/sensor tree is walked.
|
|
4
|
+
*
|
|
5
|
+
* @param {object} plant - plant with shops and optional topology()
|
|
6
|
+
* @returns {object} { plant, nodes, links }
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* topologyFromPlant(plant);
|
|
10
|
+
*/
|
|
11
|
+
function assertGraph(graph) {
|
|
12
|
+
if (!graph || typeof graph !== 'object') {
|
|
13
|
+
throw new Error(`topology graph is missing: ${graph}`);
|
|
14
|
+
}
|
|
15
|
+
if (!graph.plant || typeof graph.plant.id !== 'string' || typeof graph.plant.title !== 'string') {
|
|
16
|
+
throw new Error(`topology plant id and title are required: ${JSON.stringify(graph.plant)}`);
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(graph.nodes) || !Array.isArray(graph.links)) {
|
|
19
|
+
throw new Error('topology nodes and links arrays are required');
|
|
20
|
+
}
|
|
21
|
+
graph.nodes.forEach((node) => {
|
|
22
|
+
if (typeof node.id !== 'string' || typeof node.kind !== 'string' || typeof node.title !== 'string') {
|
|
23
|
+
throw new Error(`topology node requires id, kind, title: ${JSON.stringify(node)}`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
graph.links.forEach((link) => {
|
|
27
|
+
if (typeof link.from !== 'string' || typeof link.to !== 'string' || typeof link.kind !== 'string') {
|
|
28
|
+
throw new Error(`topology link requires from, to, kind: ${JSON.stringify(link)}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return graph;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectors(machineId, item) {
|
|
35
|
+
const nodes = [];
|
|
36
|
+
const links = [];
|
|
37
|
+
Object.keys(item.sensors || {}).forEach((key) => {
|
|
38
|
+
nodes.push({
|
|
39
|
+
id: `${machineId}-${key}`,
|
|
40
|
+
kind: 'collector',
|
|
41
|
+
title: key,
|
|
42
|
+
parent: machineId,
|
|
43
|
+
status: 'unknown'
|
|
44
|
+
});
|
|
45
|
+
links.push({ from: machineId, to: `${machineId}-${key}`, kind: 'contains' });
|
|
46
|
+
});
|
|
47
|
+
return { nodes, links };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shopGraph(shopId, area) {
|
|
51
|
+
const nodes = [{ id: shopId, kind: 'shop', title: area.name(), status: 'unknown' }];
|
|
52
|
+
const links = [];
|
|
53
|
+
Object.entries(area.machines.get()).forEach(([machineId, item]) => {
|
|
54
|
+
nodes.push({
|
|
55
|
+
id: machineId,
|
|
56
|
+
kind: 'machine',
|
|
57
|
+
title: item.name(),
|
|
58
|
+
parent: shopId,
|
|
59
|
+
status: 'unknown'
|
|
60
|
+
});
|
|
61
|
+
links.push({ from: shopId, to: machineId, kind: 'contains' });
|
|
62
|
+
const nested = collectors(machineId, item);
|
|
63
|
+
nodes.push(...nested.nodes);
|
|
64
|
+
links.push(...nested.links);
|
|
65
|
+
});
|
|
66
|
+
return { nodes, links };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function walkGraph(plant) {
|
|
70
|
+
const nodes = [];
|
|
71
|
+
const links = [];
|
|
72
|
+
Object.entries(plant.shops.get()).forEach(([shopId, area]) => {
|
|
73
|
+
const piece = shopGraph(shopId, area);
|
|
74
|
+
nodes.push(...piece.nodes);
|
|
75
|
+
links.push(...piece.links);
|
|
76
|
+
});
|
|
77
|
+
return { plant: { id: 'plant', title: 'plant' }, nodes, links };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function topologyFromPlant(plant) {
|
|
81
|
+
if (typeof plant.topology === 'function') {
|
|
82
|
+
return assertGraph(plant.topology());
|
|
83
|
+
}
|
|
84
|
+
return walkGraph(plant);
|
|
85
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -3,16 +3,18 @@
|
|
|
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
7
|
* @returns {object} plant with shops property and init method
|
|
7
8
|
*
|
|
8
9
|
* @example
|
|
9
|
-
* const p = plant(initialized({ area: shop }, Object.values));
|
|
10
|
+
* const p = plant(initialized({ area: shop }, Object.values), { topology: () => graph });
|
|
10
11
|
* p.init();
|
|
11
12
|
*/
|
|
12
13
|
export default function plant(shops, options = {}) {
|
|
13
14
|
return {
|
|
14
15
|
shops,
|
|
15
16
|
operations: options.operations,
|
|
17
|
+
topology: options.topology,
|
|
16
18
|
init() {
|
|
17
19
|
shops.init();
|
|
18
20
|
}
|
|
@@ -9,7 +9,7 @@ import sseConnection from './sseConnection.js';
|
|
|
9
9
|
* @param {function} fetcher - fetch function
|
|
10
10
|
* @param {function} eventSource - EventSource constructor
|
|
11
11
|
* @param {object} [logger] - optional logger with error(tag, detail)
|
|
12
|
-
* @returns {object} client with machines, machine, jump, reset, simulation methods
|
|
12
|
+
* @returns {object} client with machines, topology, machine, jump, reset, simulation methods
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
15
|
* const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource, logger);
|
|
@@ -45,6 +45,9 @@ export default function scadaClient(baseUrl, fetcher, eventSource, logger) {
|
|
|
45
45
|
machines() {
|
|
46
46
|
return requestJson('/machines');
|
|
47
47
|
},
|
|
48
|
+
topology() {
|
|
49
|
+
return requestJson('/topology');
|
|
50
|
+
},
|
|
48
51
|
tagCatalog() {
|
|
49
52
|
return requestJson('/tag-catalog');
|
|
50
53
|
},
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loopback Host Chromium accepts for HTTP CDP.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} port - debug port
|
|
7
|
+
* @returns {string} Host header value
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* cdpRequest({ host: '192.0.2.10', port: 9222 }, '/json/list');
|
|
11
|
+
*/
|
|
12
|
+
export function cdpRequest(target, path) {
|
|
13
|
+
return {
|
|
14
|
+
hostname: target.host,
|
|
15
|
+
port: target.port,
|
|
16
|
+
path,
|
|
17
|
+
method: 'GET',
|
|
18
|
+
headers: { Host: `127.0.0.1:${target.port}` }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rewrites CDP debugger URLs onto the same-origin inspect prefix.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text - upstream JSON or HTML
|
|
26
|
+
* @param {string} hostHeader - incoming Host
|
|
27
|
+
* @param {string} nodeId - topology node id
|
|
28
|
+
* @returns {string} rewritten body
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* rewriteDebugger(body, 'example.test:443', 'm1');
|
|
32
|
+
*/
|
|
33
|
+
export function rewriteDebugger(text, hostHeader, nodeId) {
|
|
34
|
+
const secure = hostHeader.endsWith(':443');
|
|
35
|
+
const scheme = secure ? 'wss' : 'ws';
|
|
36
|
+
const host = hostHeader.replace(/:443$/u, '').replace(/:80$/u, '');
|
|
37
|
+
const prefix = `/infra/inspect/${nodeId}`;
|
|
38
|
+
const origin = `${scheme}://${host}${prefix}`;
|
|
39
|
+
const query = `$<scheme>=${host}${prefix}`;
|
|
40
|
+
return text
|
|
41
|
+
.replace(/"\/devtools\//gu, `"${prefix}/devtools/`)
|
|
42
|
+
.replace(/wss?:\/\/127\.0\.0\.1:\d+/gu, origin)
|
|
43
|
+
.replace(/(?<scheme>wss?)=127\.0\.0\.1:\d+/gu, query);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readBody(res) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
res.on('data', (chunk) => {
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
});
|
|
52
|
+
res.on('end', () => {
|
|
53
|
+
resolve(Buffer.concat(chunks));
|
|
54
|
+
});
|
|
55
|
+
res.on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestCdp(target, path) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const req = http.request(cdpRequest(target, path), (res) => {
|
|
62
|
+
readBody(res).then((body) => {
|
|
63
|
+
resolve({ status: res.statusCode, headers: res.headers, body });
|
|
64
|
+
}, reject);
|
|
65
|
+
});
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function capturePng(wsUrl) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const socket = new globalThis.WebSocket(wsUrl);
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
socket.close();
|
|
76
|
+
reject(new Error(`cdp screenshot timed out for ${wsUrl}`));
|
|
77
|
+
}, 10000);
|
|
78
|
+
socket.addEventListener('error', (err) => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
reject(err);
|
|
81
|
+
});
|
|
82
|
+
socket.addEventListener('open', () => {
|
|
83
|
+
socket.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }));
|
|
84
|
+
});
|
|
85
|
+
socket.addEventListener('message', (event) => {
|
|
86
|
+
const message = JSON.parse(String(event.data));
|
|
87
|
+
if (message.id !== 1) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
socket.close();
|
|
92
|
+
resolve(Buffer.from(message.result.data, 'base64'));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function defaultShot(target, requestImpl) {
|
|
98
|
+
const listed = await requestImpl(target, '/json/list');
|
|
99
|
+
const pages = JSON.parse(listed.body.toString('utf8'));
|
|
100
|
+
const page = Array.isArray(pages) ? pages[0] : pages;
|
|
101
|
+
if (!page || !page.webSocketDebuggerUrl) {
|
|
102
|
+
throw new Error(`cdp page websocket is missing on ${target.host}:${target.port}`);
|
|
103
|
+
}
|
|
104
|
+
const wsUrl = page.webSocketDebuggerUrl.replace(/wss?:\/\/127\.0\.0\.1:\d+/u, `ws://${target.host}:${target.port}`);
|
|
105
|
+
return capturePng(wsUrl);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* HTTP CDP client that forces a loopback Host and rewrites debugger URLs.
|
|
110
|
+
*
|
|
111
|
+
* @param {function} [requestImpl] - optional (target, path) => { status, headers, body }
|
|
112
|
+
* @param {function} [shotImpl] - optional (target) => Buffer png
|
|
113
|
+
* @returns {object} json, shot, proxy
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const body = await cdpGateway().json(target, '/json/list', 'example.test:443', 'm1');
|
|
117
|
+
*/
|
|
118
|
+
export default function cdpGateway(requestImpl, shotImpl) {
|
|
119
|
+
const request = requestImpl || requestCdp;
|
|
120
|
+
const shot = shotImpl || ((target) => {
|
|
121
|
+
return defaultShot(target, request);
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
async json(target, path, hostHeader, nodeId) {
|
|
125
|
+
const result = await request(target, path);
|
|
126
|
+
return rewriteDebugger(result.body.toString('utf8'), hostHeader, nodeId);
|
|
127
|
+
},
|
|
128
|
+
shot(target) {
|
|
129
|
+
return shot(target);
|
|
130
|
+
},
|
|
131
|
+
proxy(target, path, res) {
|
|
132
|
+
const req = http.request(cdpRequest(target, path), (up) => {
|
|
133
|
+
res.writeHead(up.statusCode, up.headers);
|
|
134
|
+
up.pipe(res);
|
|
135
|
+
});
|
|
136
|
+
req.on('error', (err) => {
|
|
137
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
138
|
+
res.end(String(err));
|
|
139
|
+
});
|
|
140
|
+
req.end();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses /infra/inspect/:id/... into node id and upstream path.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} url - request URL
|
|
7
|
+
* @returns {{id: string, path: string}|undefined} match
|
|
8
|
+
*/
|
|
9
|
+
export function inspectUrl(url) {
|
|
10
|
+
const path = url.split('?')[0];
|
|
11
|
+
const match = path.match(/^\/infra\/inspect\/(?<id>[^/]+)\/(?<rest>.*)$/u);
|
|
12
|
+
if (!match) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return { id: match.groups.id, path: `/${match.groups.rest}` };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function inspectOf(plant, id) {
|
|
19
|
+
if (typeof plant.topology !== 'function') {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const node = plant.topology().nodes.find((item) => {
|
|
23
|
+
return item.id === id;
|
|
24
|
+
});
|
|
25
|
+
return node && node.inspect;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeUpgrade(upstream, req, target, path, head) {
|
|
29
|
+
const lines = [
|
|
30
|
+
`GET ${path} HTTP/1.1`,
|
|
31
|
+
`Host: 127.0.0.1:${target.port}`,
|
|
32
|
+
'Connection: Upgrade',
|
|
33
|
+
'Upgrade: websocket',
|
|
34
|
+
`Sec-WebSocket-Version: ${req.headers['sec-websocket-version'] || '13'}`,
|
|
35
|
+
`Sec-WebSocket-Key: ${req.headers['sec-websocket-key']}`
|
|
36
|
+
];
|
|
37
|
+
if (req.headers['sec-websocket-protocol']) {
|
|
38
|
+
lines.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`);
|
|
39
|
+
}
|
|
40
|
+
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
41
|
+
if (head && head.length) {
|
|
42
|
+
upstream.write(head);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Upgrades a same-origin inspect websocket onto the node debug port.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} plant - plant with topology()
|
|
50
|
+
* @param {object} req - HTTP upgrade request
|
|
51
|
+
* @param {object} socket - client socket
|
|
52
|
+
* @param {Buffer} head - leftover bytes
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* server.on('upgrade', (req, socket, head) => inspectUpgrade(plant, req, socket, head));
|
|
57
|
+
*/
|
|
58
|
+
export default function inspectUpgrade(plant, req, socket, head) {
|
|
59
|
+
const match = inspectUrl(req.url);
|
|
60
|
+
const target = match ? inspectOf(plant, match.id) : undefined;
|
|
61
|
+
if (!match || !target) {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const upstream = net.connect(target.port, target.host, () => {
|
|
66
|
+
writeUpgrade(upstream, req, target, match.path, head);
|
|
67
|
+
upstream.pipe(socket);
|
|
68
|
+
socket.pipe(upstream);
|
|
69
|
+
});
|
|
70
|
+
upstream.on('error', () => {
|
|
71
|
+
socket.destroy();
|
|
72
|
+
});
|
|
73
|
+
socket.on('error', () => {
|
|
74
|
+
upstream.destroy();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { route } from '@yarkivaev/simple-server';
|
|
5
|
+
|
|
6
|
+
const dir = join(dirname(fileURLToPath(import.meta.url)), '../ui');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sends a static file next to the infra UI module.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} res - HTTP response
|
|
12
|
+
* @param {string} name - file path relative to ui/
|
|
13
|
+
* @param {string} type - Content-Type
|
|
14
|
+
* @returns {void}
|
|
15
|
+
*/
|
|
16
|
+
function sendFile(res, name, type) {
|
|
17
|
+
res.writeHead(200, { 'Content-Type': type });
|
|
18
|
+
res.end(readFileSync(join(dir, name)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Site-server topology page and its static assets.
|
|
23
|
+
*
|
|
24
|
+
* @returns {array} route objects
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* infraRoute();
|
|
28
|
+
*/
|
|
29
|
+
export default function infraRoute() {
|
|
30
|
+
return [
|
|
31
|
+
route('GET', '/infra', (req, res) => {
|
|
32
|
+
sendFile(res, 'infra.html', 'text/html; charset=utf-8');
|
|
33
|
+
}),
|
|
34
|
+
route('GET', '/infra/', (req, res) => {
|
|
35
|
+
sendFile(res, 'infra.html', 'text/html; charset=utf-8');
|
|
36
|
+
}),
|
|
37
|
+
route('GET', '/infra/app.js', (req, res) => {
|
|
38
|
+
sendFile(res, 'infra/app.js', 'text/javascript; charset=utf-8');
|
|
39
|
+
}),
|
|
40
|
+
route('GET', '/infra/app.css', (req, res) => {
|
|
41
|
+
sendFile(res, 'infra/app.css', 'text/css; charset=utf-8');
|
|
42
|
+
}),
|
|
43
|
+
route('GET', '/infra/graph.js', (req, res) => {
|
|
44
|
+
sendFile(res, 'infra/graph.js', 'text/javascript; charset=utf-8');
|
|
45
|
+
}),
|
|
46
|
+
route('GET', '/infra/panel.js', (req, res) => {
|
|
47
|
+
sendFile(res, 'infra/panel.js', 'text/javascript; charset=utf-8');
|
|
48
|
+
})
|
|
49
|
+
];
|
|
50
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { errorResponse } from '@yarkivaev/simple-server';
|
|
2
|
+
import cdpGateway from '../inspect/cdpGateway.js';
|
|
3
|
+
import { inspectUrl } from '../inspect/inspectUpgrade.js';
|
|
4
|
+
|
|
5
|
+
function inspectOf(plant, id) {
|
|
6
|
+
if (typeof plant.topology !== 'function') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const node = plant.topology().nodes.find((item) => {
|
|
10
|
+
return item.id === id;
|
|
11
|
+
});
|
|
12
|
+
return node && node.inspect;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hostOf(req) {
|
|
16
|
+
return req.headers.host || req.headers.Host || 'localhost';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cdpPath(rest) {
|
|
20
|
+
if (rest === '/json' || rest === '/json/list') {
|
|
21
|
+
return '/json/list';
|
|
22
|
+
}
|
|
23
|
+
if (rest === '/json/version') {
|
|
24
|
+
return '/json/version';
|
|
25
|
+
}
|
|
26
|
+
if (rest.startsWith('/devtools/')) {
|
|
27
|
+
return rest;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function missing(res, url) {
|
|
33
|
+
errorResponse('NOT_FOUND', `Inspect path '${url}' was not found`, 404).send(res);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function queryOf(url) {
|
|
37
|
+
return url.includes('?') ? `?${url.split('?')[1]}` : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function sendJson(cdp, target, req, res, nodeId) {
|
|
41
|
+
const path = cdpPath(inspectUrl(req.url).path);
|
|
42
|
+
const body = await cdp.json(target, path, hostOf(req), nodeId);
|
|
43
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
44
|
+
res.end(body);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sendShot(cdp, target, res) {
|
|
48
|
+
const png = await cdp.shot(target);
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
50
|
+
res.end(png);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function sendCdp(cdp, target, req, res, match) {
|
|
54
|
+
const path = cdpPath(match.path);
|
|
55
|
+
if (!path) {
|
|
56
|
+
missing(res, req.url);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (path.startsWith('/devtools/')) {
|
|
60
|
+
cdp.proxy(target, `${path}${queryOf(req.url)}`, res);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await sendJson(cdp, target, req, res, match.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function dispatchInspect(plant, cdp, req, res, match) {
|
|
67
|
+
const target = inspectOf(plant, match.id);
|
|
68
|
+
if (!target) {
|
|
69
|
+
missing(res, req.url);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (match.path === '/shot') {
|
|
73
|
+
await sendShot(cdp, target, res);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await sendCdp(cdp, target, req, res, match);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function dispatch(plant, cdp, req, res) {
|
|
80
|
+
const match = inspectUrl(req.url);
|
|
81
|
+
if (!match) {
|
|
82
|
+
missing(res, req.url);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await dispatchInspect(plant, cdp, req, res, match);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Same-origin CDP inspect routes for nodes that declare inspect { host, port }.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} plant - plant with topology()
|
|
92
|
+
* @param {object} [gateway] - optional cdpGateway
|
|
93
|
+
* @returns {array} route objects
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* inspectRoute(plant);
|
|
97
|
+
*/
|
|
98
|
+
export default function inspectRoute(plant, gateway) {
|
|
99
|
+
const cdp = gateway || cdpGateway();
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
matches(request) {
|
|
103
|
+
return request.method === 'GET' && Boolean(inspectUrl(request.url));
|
|
104
|
+
},
|
|
105
|
+
handle(request, response) {
|
|
106
|
+
return dispatch(plant, cdp, request, response);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
];
|
|
110
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
2
|
+
import topologyFromPlant from '../../../../application/topologyFromPlant.js';
|
|
3
|
+
import topologyHealth from '../../../../application/topologyHealth.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plant topology graph route.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - API base path
|
|
9
|
+
* @param {object} plant - plant domain object
|
|
10
|
+
* @param {function} [clock] - () => Date for sensor freshness
|
|
11
|
+
* @returns {array} route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* topologyRoute('/api/v1', plant);
|
|
15
|
+
*/
|
|
16
|
+
export default function topologyRoute(basePath, plant, clock) {
|
|
17
|
+
return [
|
|
18
|
+
route('GET', `${basePath}/topology`, async (req, res) => {
|
|
19
|
+
const graph = await topologyHealth(topologyFromPlant(plant), plant, clock);
|
|
20
|
+
jsonResponse(graph).send(res);
|
|
21
|
+
})
|
|
22
|
+
];
|
|
23
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--ink: #d7e0ea;
|
|
3
|
+
--muted: #8ea0b3;
|
|
4
|
+
--bg: #0b1118;
|
|
5
|
+
--panel: #121a24;
|
|
6
|
+
--line: #243140;
|
|
7
|
+
--ok: #3f9d6a;
|
|
8
|
+
--degraded: #c9a227;
|
|
9
|
+
--down: #c24b3a;
|
|
10
|
+
--unknown: #5d6d7e;
|
|
11
|
+
--accent: #7eb1c9;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html,
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
min-height: 100%;
|
|
22
|
+
background: radial-gradient(circle at 12% 0, #152033 0, var(--bg) 42%);
|
|
23
|
+
color: var(--ink);
|
|
24
|
+
font: 14px/1.4 "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.mast {
|
|
28
|
+
padding: 20px 28px 8px;
|
|
29
|
+
border-bottom: 1px solid var(--line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.mast__kicker {
|
|
33
|
+
margin: 0;
|
|
34
|
+
letter-spacing: 0.18em;
|
|
35
|
+
text-transform: uppercase;
|
|
36
|
+
color: var(--accent);
|
|
37
|
+
font-size: 11px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.mast h1 {
|
|
41
|
+
margin: 4px 0 0;
|
|
42
|
+
font-size: 28px;
|
|
43
|
+
font-weight: 560;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.mast__meta {
|
|
47
|
+
margin: 4px 0 0;
|
|
48
|
+
color: var(--muted);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.stage {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: minmax(0, 1fr) 320px;
|
|
54
|
+
min-height: calc(100vh - 92px);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.stage__graph {
|
|
58
|
+
width: 100%;
|
|
59
|
+
height: calc(100vh - 92px);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.stage__panel {
|
|
63
|
+
border-left: 1px solid var(--line);
|
|
64
|
+
background: var(--panel);
|
|
65
|
+
padding: 20px;
|
|
66
|
+
overflow: auto;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.stage__panel[hidden] {
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.node {
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.node__shape {
|
|
78
|
+
stroke: #0b1118;
|
|
79
|
+
stroke-width: 2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.node--ok .node__shape { fill: var(--ok); }
|
|
83
|
+
.node--degraded .node__shape { fill: var(--degraded); }
|
|
84
|
+
.node--down .node__shape { fill: var(--down); }
|
|
85
|
+
.node--unknown .node__shape { fill: var(--unknown); }
|
|
86
|
+
|
|
87
|
+
.node__label,
|
|
88
|
+
.node__version {
|
|
89
|
+
fill: var(--ink);
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.link {
|
|
94
|
+
fill: none;
|
|
95
|
+
stroke: #3a5166;
|
|
96
|
+
stroke-width: 1.4;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.link--feeds {
|
|
100
|
+
stroke-dasharray: 5 4;
|
|
101
|
+
stroke: #7eb1c9;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.panel__kind {
|
|
105
|
+
color: var(--muted);
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
letter-spacing: 0.12em;
|
|
108
|
+
font-size: 11px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.panel__title {
|
|
112
|
+
margin: 6px 0 16px;
|
|
113
|
+
font-size: 22px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.panel__row {
|
|
117
|
+
display: flex;
|
|
118
|
+
justify-content: space-between;
|
|
119
|
+
gap: 12px;
|
|
120
|
+
padding: 8px 0;
|
|
121
|
+
border-bottom: 1px solid var(--line);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.panel__actions {
|
|
125
|
+
display: flex;
|
|
126
|
+
gap: 8px;
|
|
127
|
+
margin-top: 18px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.panel__actions button {
|
|
131
|
+
background: #1b2836;
|
|
132
|
+
color: var(--ink);
|
|
133
|
+
border: 1px solid #314559;
|
|
134
|
+
border-radius: 4px;
|
|
135
|
+
padding: 8px 12px;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.panel__preview,
|
|
140
|
+
.panel__tools {
|
|
141
|
+
width: 100%;
|
|
142
|
+
margin-top: 16px;
|
|
143
|
+
background: #0b1118;
|
|
144
|
+
border: 1px solid var(--line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.panel__preview {
|
|
148
|
+
min-height: 140px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.panel__tools {
|
|
152
|
+
min-height: 280px;
|
|
153
|
+
border: 0;
|
|
154
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import draw from './graph.js';
|
|
2
|
+
import show from './panel.js';
|
|
3
|
+
|
|
4
|
+
const title = document.getElementById('plant-title');
|
|
5
|
+
const meta = document.getElementById('plant-meta');
|
|
6
|
+
const svg = document.getElementById('graph');
|
|
7
|
+
const aside = document.getElementById('panel');
|
|
8
|
+
const graph = await (await fetch('/api/v1/topology')).json();
|
|
9
|
+
|
|
10
|
+
title.textContent = graph.plant.title;
|
|
11
|
+
meta.textContent = [graph.plant.id, graph.plant.version].filter(Boolean).join(' · ');
|
|
12
|
+
draw(svg, graph, (node) => {
|
|
13
|
+
show(aside, node);
|
|
14
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
2
|
+
|
|
3
|
+
function shops(nodes) {
|
|
4
|
+
return nodes.filter((node) => {
|
|
5
|
+
return node.kind === 'shop';
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function children(nodes, parentId, kind) {
|
|
10
|
+
return nodes.filter((node) => {
|
|
11
|
+
return node.parent === parentId && node.kind === kind;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function placed(graph) {
|
|
16
|
+
const positions = {};
|
|
17
|
+
shops(graph.nodes).forEach((area, i) => {
|
|
18
|
+
const x = 170 + i * 280;
|
|
19
|
+
positions[area.id] = { x, y: 56 };
|
|
20
|
+
children(graph.nodes, area.id, 'machine').forEach((item, j) => {
|
|
21
|
+
const y = 150 + j * 96;
|
|
22
|
+
positions[item.id] = { x, y };
|
|
23
|
+
children(graph.nodes, item.id, 'collector').forEach((col, k) => {
|
|
24
|
+
positions[col.id] = { x: x + 96, y: y + k * 26 };
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
graph.nodes.filter((node) => {
|
|
29
|
+
return node.kind === 'collector' && !positions[node.id];
|
|
30
|
+
}).forEach((node, i) => {
|
|
31
|
+
positions[node.id] = { x: 72, y: 88 + i * 54 };
|
|
32
|
+
});
|
|
33
|
+
return positions;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function el(name, attrs) {
|
|
37
|
+
const node = document.createElementNS(SVG, name);
|
|
38
|
+
Object.entries(attrs).forEach(([key, value]) => {
|
|
39
|
+
node.setAttribute(key, String(value));
|
|
40
|
+
});
|
|
41
|
+
return node;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function paintLink(svg, graph, positions) {
|
|
45
|
+
graph.links.forEach((link) => {
|
|
46
|
+
const from = positions[link.from];
|
|
47
|
+
const to = positions[link.to];
|
|
48
|
+
if (!from || !to) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const line = el('path', { class: `link link--${link.kind}` });
|
|
52
|
+
line.setAttribute('d', `M ${from.x} ${from.y} C ${from.x} ${(from.y + to.y) / 2}, ${to.x} ${(from.y + to.y) / 2}, ${to.x} ${to.y}`);
|
|
53
|
+
svg.appendChild(line);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function paintShape(node, pos) {
|
|
58
|
+
if (node.kind === 'machine') {
|
|
59
|
+
return el('rect', { class: 'node__shape', x: pos.x - 42, y: pos.y - 18, width: 84, height: 36, rx: 4 });
|
|
60
|
+
}
|
|
61
|
+
const radius = node.kind === 'shop' ? 22 : 10;
|
|
62
|
+
return el('circle', { class: 'node__shape', cx: pos.x, cy: pos.y, r: radius });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function paintNode(svg, node, pos, onClick) {
|
|
66
|
+
const group = el('g', { class: `node node--${node.status || 'unknown'}`, 'data-id': node.id });
|
|
67
|
+
const label = el('text', { class: 'node__label', x: pos.x, y: pos.y + 36, 'text-anchor': 'middle' });
|
|
68
|
+
label.textContent = node.title;
|
|
69
|
+
group.appendChild(paintShape(node, pos));
|
|
70
|
+
group.appendChild(label);
|
|
71
|
+
if (node.version) {
|
|
72
|
+
const version = el('text', { class: 'node__version', x: pos.x, y: pos.y + 50, 'text-anchor': 'middle' });
|
|
73
|
+
version.textContent = node.version;
|
|
74
|
+
group.appendChild(version);
|
|
75
|
+
}
|
|
76
|
+
group.addEventListener('click', () => {
|
|
77
|
+
onClick(node);
|
|
78
|
+
});
|
|
79
|
+
svg.appendChild(group);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Draws a parent/kind column graph into an SVG element.
|
|
84
|
+
*
|
|
85
|
+
* @param {SVGElement} svg - target
|
|
86
|
+
* @param {object} graph - topology contract
|
|
87
|
+
* @param {function} onClick - selected node callback
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* draw(svg, graph, (node) => show(panel, node));
|
|
92
|
+
*/
|
|
93
|
+
export default function draw(svg, graph, onClick) {
|
|
94
|
+
while (svg.firstChild) {
|
|
95
|
+
svg.removeChild(svg.firstChild);
|
|
96
|
+
}
|
|
97
|
+
const columns = Math.max(shops(graph.nodes).length, 1);
|
|
98
|
+
svg.setAttribute('viewBox', `0 0 ${Math.max(960, columns * 280 + 120)} 520`);
|
|
99
|
+
const positions = placed(graph);
|
|
100
|
+
paintLink(svg, graph, positions);
|
|
101
|
+
graph.nodes.forEach((node) => {
|
|
102
|
+
if (positions[node.id]) {
|
|
103
|
+
paintNode(svg, node, positions[node.id], onClick);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
function row(label, value) {
|
|
2
|
+
const line = document.createElement('div');
|
|
3
|
+
line.className = 'panel__row';
|
|
4
|
+
const name = document.createElement('span');
|
|
5
|
+
name.textContent = label;
|
|
6
|
+
const body = document.createElement('span');
|
|
7
|
+
body.textContent = value;
|
|
8
|
+
line.appendChild(name);
|
|
9
|
+
line.appendChild(body);
|
|
10
|
+
return line;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function button(label, action) {
|
|
14
|
+
const el = document.createElement('button');
|
|
15
|
+
el.type = 'button';
|
|
16
|
+
el.textContent = label;
|
|
17
|
+
el.addEventListener('click', action);
|
|
18
|
+
return el;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function preview(id) {
|
|
22
|
+
const img = document.createElement('img');
|
|
23
|
+
img.className = 'panel__preview';
|
|
24
|
+
img.alt = 'Screen preview';
|
|
25
|
+
img.src = `/infra/inspect/${id}/shot`;
|
|
26
|
+
return img;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function tools(id, host) {
|
|
30
|
+
const frame = document.createElement('iframe');
|
|
31
|
+
frame.className = 'panel__tools';
|
|
32
|
+
frame.title = 'DevTools';
|
|
33
|
+
const response = await fetch(`/infra/inspect/${id}/json/list`);
|
|
34
|
+
const payload = await response.json();
|
|
35
|
+
const page = Array.isArray(payload) ? payload[0] : payload;
|
|
36
|
+
if (page && page.devtoolsFrontendUrl) {
|
|
37
|
+
frame.src = page.devtoolsFrontendUrl;
|
|
38
|
+
return frame;
|
|
39
|
+
}
|
|
40
|
+
frame.srcdoc = `<p>${host}</p>`;
|
|
41
|
+
return frame;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function facts(aside, node) {
|
|
45
|
+
aside.appendChild(row('id', node.id));
|
|
46
|
+
aside.appendChild(row('status', node.status || 'unknown'));
|
|
47
|
+
if (node.version) {
|
|
48
|
+
aside.appendChild(row('version', node.version));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function actions(aside, node) {
|
|
53
|
+
const bar = document.createElement('div');
|
|
54
|
+
bar.className = 'panel__actions';
|
|
55
|
+
bar.appendChild(button('Preview', () => {
|
|
56
|
+
aside.appendChild(preview(node.id));
|
|
57
|
+
}));
|
|
58
|
+
bar.appendChild(button('DevTools', async () => {
|
|
59
|
+
aside.appendChild(await tools(node.id, node.inspect.host));
|
|
60
|
+
}));
|
|
61
|
+
aside.appendChild(bar);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fills the topology side panel for one node.
|
|
66
|
+
*
|
|
67
|
+
* @param {HTMLElement} aside - panel root
|
|
68
|
+
* @param {object} node - topology node
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* show(panel, node);
|
|
73
|
+
*/
|
|
74
|
+
export default function show(aside, node) {
|
|
75
|
+
aside.hidden = false;
|
|
76
|
+
aside.replaceChildren();
|
|
77
|
+
const kind = document.createElement('p');
|
|
78
|
+
kind.className = 'panel__kind';
|
|
79
|
+
kind.textContent = node.kind;
|
|
80
|
+
const title = document.createElement('h2');
|
|
81
|
+
title.className = 'panel__title';
|
|
82
|
+
title.textContent = node.title;
|
|
83
|
+
aside.appendChild(kind);
|
|
84
|
+
aside.appendChild(title);
|
|
85
|
+
facts(aside, node);
|
|
86
|
+
if (node.inspect) {
|
|
87
|
+
actions(aside, node);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Plant topology</title>
|
|
7
|
+
<link rel="stylesheet" href="/infra/app.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="mast">
|
|
11
|
+
<p class="mast__kicker">Admin</p>
|
|
12
|
+
<h1 id="plant-title">Topology</h1>
|
|
13
|
+
<p id="plant-meta" class="mast__meta"></p>
|
|
14
|
+
</header>
|
|
15
|
+
<main class="stage">
|
|
16
|
+
<svg id="graph" class="stage__graph" role="img" aria-label="Plant topology"></svg>
|
|
17
|
+
<aside id="panel" class="stage__panel" hidden></aside>
|
|
18
|
+
</main>
|
|
19
|
+
<script type="module" src="/infra/app.js"></script>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|