@yarkivaev/scada 2.3.64 → 2.3.66
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 +1 -1
- package/src/infrastructure/http/plant/inspect/cdpGateway.js +35 -45
- package/src/infrastructure/http/plant/inspect/cdpShot.js +122 -0
- package/src/infrastructure/http/plant/routes/infraRoute.js +3 -0
- package/src/infrastructure/http/plant/routes/inspectRoute.js +5 -1
- package/src/infrastructure/http/plant/ui/infra/graph.js +14 -32
- package/src/infrastructure/http/plant/ui/infra/graphLayout.js +68 -0
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import capturePng from './cdpShot.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Loopback Host Chromium accepts for HTTP CDP.
|
|
@@ -19,28 +20,54 @@ export function cdpRequest(target, path) {
|
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Chooses ws or wss from Host and X-Forwarded-Proto.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} hostHeader - incoming Host
|
|
27
|
+
* @param {string} [proto] - forwarded proto
|
|
28
|
+
* @returns {string} ws or wss
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* schemeOf('scada.example');
|
|
32
|
+
*/
|
|
33
|
+
export function schemeOf(hostHeader, proto) {
|
|
34
|
+
if (proto === 'https') {
|
|
35
|
+
return 'wss';
|
|
36
|
+
}
|
|
37
|
+
if (proto === 'http') {
|
|
38
|
+
return 'ws';
|
|
39
|
+
}
|
|
40
|
+
if (hostHeader.endsWith(':443')) {
|
|
41
|
+
return 'wss';
|
|
42
|
+
}
|
|
43
|
+
return hostHeader.includes(':') ? 'ws' : 'wss';
|
|
44
|
+
}
|
|
45
|
+
|
|
22
46
|
/**
|
|
23
47
|
* Rewrites CDP debugger URLs onto the same-origin inspect prefix.
|
|
24
48
|
*
|
|
25
49
|
* @param {string} text - upstream JSON or HTML
|
|
26
50
|
* @param {string} hostHeader - incoming Host
|
|
27
51
|
* @param {string} nodeId - topology node id
|
|
52
|
+
* @param {string} [proto] - forwarded proto
|
|
28
53
|
* @returns {string} rewritten body
|
|
29
54
|
*
|
|
30
55
|
* @example
|
|
31
56
|
* rewriteDebugger(body, 'example.test:443', 'm1');
|
|
32
57
|
*/
|
|
33
|
-
export function rewriteDebugger(text, hostHeader, nodeId) {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
58
|
+
export function rewriteDebugger(text, hostHeader, nodeId, proto) {
|
|
59
|
+
const scheme = schemeOf(hostHeader, proto);
|
|
60
|
+
const httpScheme = scheme === 'wss' ? 'https' : 'http';
|
|
36
61
|
const host = hostHeader.replace(/:443$/u, '').replace(/:80$/u, '');
|
|
37
62
|
const prefix = `/infra/inspect/${nodeId}`;
|
|
38
63
|
const origin = `${scheme}://${host}${prefix}`;
|
|
39
|
-
const query =
|
|
64
|
+
const query = `${scheme}=${host}${prefix}`;
|
|
65
|
+
const frontend = `${httpScheme}://${host}${prefix}/devtools/inspector.html`;
|
|
40
66
|
return text
|
|
67
|
+
.replace(/https:\/\/chrome-devtools-frontend\.appspot\.com\/serve_rev\/@[^"'?\s]+\/inspector\.html/gu, frontend)
|
|
41
68
|
.replace(/"\/devtools\//gu, `"${prefix}/devtools/`)
|
|
42
69
|
.replace(/wss?:\/\/127\.0\.0\.1:\d+/gu, origin)
|
|
43
|
-
.replace(/(
|
|
70
|
+
.replace(/(?:wss?)=127\.0\.0\.1:\d+/gu, query);
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
function readBody(res) {
|
|
@@ -68,43 +95,6 @@ function requestCdp(target, path) {
|
|
|
68
95
|
});
|
|
69
96
|
}
|
|
70
97
|
|
|
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
98
|
/**
|
|
109
99
|
* HTTP CDP client that forces a loopback Host and rewrites debugger URLs.
|
|
110
100
|
*
|
|
@@ -118,12 +108,12 @@ async function defaultShot(target, requestImpl) {
|
|
|
118
108
|
export default function cdpGateway(requestImpl, shotImpl) {
|
|
119
109
|
const request = requestImpl || requestCdp;
|
|
120
110
|
const shot = shotImpl || ((target) => {
|
|
121
|
-
return
|
|
111
|
+
return capturePng(target, request);
|
|
122
112
|
});
|
|
123
113
|
return {
|
|
124
|
-
async json(target, path, hostHeader, nodeId) {
|
|
114
|
+
async json(target, path, hostHeader, nodeId, proto) {
|
|
125
115
|
const result = await request(target, path);
|
|
126
|
-
return rewriteDebugger(result.body.toString('utf8'), hostHeader, nodeId);
|
|
116
|
+
return rewriteDebugger(result.body.toString('utf8'), hostHeader, nodeId, proto);
|
|
127
117
|
},
|
|
128
118
|
shot(target) {
|
|
129
119
|
return shot(target);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function encode(text) {
|
|
5
|
+
const payload = Buffer.from(text, 'utf8');
|
|
6
|
+
const mask = crypto.randomBytes(4);
|
|
7
|
+
const size = payload.length < 126 ? payload.length : 126;
|
|
8
|
+
const head = payload.length < 126
|
|
9
|
+
? Buffer.from([0x81, 0x80 | size])
|
|
10
|
+
: Buffer.concat([Buffer.from([0x81, 0x80 | 126]), Buffer.from([payload.length >> 8, payload.length & 255])]);
|
|
11
|
+
const masked = Buffer.from(payload);
|
|
12
|
+
for (let i = 0; i < payload.length; i += 1) {
|
|
13
|
+
masked[i] ^= mask[i % 4];
|
|
14
|
+
}
|
|
15
|
+
return Buffer.concat([head, mask, masked]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function header(buf) {
|
|
19
|
+
if (buf.length < 2) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const code = buf[1] & 127;
|
|
23
|
+
if (code < 126) {
|
|
24
|
+
return { len: code, offset: 2 };
|
|
25
|
+
}
|
|
26
|
+
if (code === 126 && buf.length >= 4) {
|
|
27
|
+
return { len: buf.readUInt16BE(2), offset: 4 };
|
|
28
|
+
}
|
|
29
|
+
if (code === 127 && buf.length >= 10) {
|
|
30
|
+
return { len: Number(buf.readBigUInt64BE(2)), offset: 10 };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decode(buf) {
|
|
36
|
+
const meta = header(buf);
|
|
37
|
+
if (!meta || buf.length < meta.offset + meta.len) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const opcode = buf[0] & 15;
|
|
41
|
+
if (opcode !== 1 && opcode !== 2) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return buf.subarray(meta.offset, meta.offset + meta.len);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openSocket(target, path) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const req = http.request({
|
|
50
|
+
hostname: target.host,
|
|
51
|
+
port: target.port,
|
|
52
|
+
path,
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
Host: `127.0.0.1:${target.port}`,
|
|
56
|
+
Connection: 'Upgrade',
|
|
57
|
+
Upgrade: 'websocket',
|
|
58
|
+
'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'),
|
|
59
|
+
'Sec-WebSocket-Version': '13'
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
req.on('upgrade', (res, socket) => {
|
|
63
|
+
resolve(socket);
|
|
64
|
+
});
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
req.end();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pathOf(wsUrl) {
|
|
71
|
+
return new URL(wsUrl.replace(/^wss:/u, 'http:').replace(/^ws:/u, 'http:')).pathname;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Captures a PNG screenshot over CDP without a global WebSocket constructor.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} target - inspect { host, port }
|
|
78
|
+
* @param {function} requestImpl - (target, path) => { body }
|
|
79
|
+
* @returns {Promise<Buffer>} png bytes
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* await capturePng({ host: '127.0.0.1', port: 9222 }, requestCdp);
|
|
83
|
+
*/
|
|
84
|
+
export default async function capturePng(target, requestImpl) {
|
|
85
|
+
const listed = await requestImpl(target, '/json/list');
|
|
86
|
+
const pages = JSON.parse(listed.body.toString('utf8'));
|
|
87
|
+
const page = Array.isArray(pages) ? pages[0] : pages;
|
|
88
|
+
if (!page || !page.webSocketDebuggerUrl) {
|
|
89
|
+
throw new Error(`cdp page websocket is missing on ${target.host}:${target.port}`);
|
|
90
|
+
}
|
|
91
|
+
const socket = await openSocket(target, pathOf(page.webSocketDebuggerUrl));
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
socket.destroy();
|
|
95
|
+
reject(new Error(`cdp screenshot timed out for ${target.host}:${target.port}`));
|
|
96
|
+
}, 10000);
|
|
97
|
+
const chunks = [];
|
|
98
|
+
socket.on('data', (chunk) => {
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
const payload = decode(Buffer.concat(chunks));
|
|
101
|
+
if (!payload) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const message = JSON.parse(payload.toString('utf8'));
|
|
105
|
+
if (message.id !== 1) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
socket.destroy();
|
|
110
|
+
resolve(Buffer.from(message.result.data, 'base64'));
|
|
111
|
+
});
|
|
112
|
+
socket.on('error', (err) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(err);
|
|
115
|
+
});
|
|
116
|
+
socket.write(encode(JSON.stringify({
|
|
117
|
+
id: 1,
|
|
118
|
+
method: 'Page.captureScreenshot',
|
|
119
|
+
params: { format: 'png' }
|
|
120
|
+
})));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -43,6 +43,9 @@ export default function infraRoute() {
|
|
|
43
43
|
route('GET', '/infra/graph.js', (req, res) => {
|
|
44
44
|
sendFile(res, 'infra/graph.js', 'text/javascript; charset=utf-8');
|
|
45
45
|
}),
|
|
46
|
+
route('GET', '/infra/graphLayout.js', (req, res) => {
|
|
47
|
+
sendFile(res, 'infra/graphLayout.js', 'text/javascript; charset=utf-8');
|
|
48
|
+
}),
|
|
46
49
|
route('GET', '/infra/panel.js', (req, res) => {
|
|
47
50
|
sendFile(res, 'infra/panel.js', 'text/javascript; charset=utf-8');
|
|
48
51
|
})
|
|
@@ -16,6 +16,10 @@ function hostOf(req) {
|
|
|
16
16
|
return req.headers.host || req.headers.Host || 'localhost';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function protoOf(req) {
|
|
20
|
+
return req.headers['x-forwarded-proto'] || req.headers['X-Forwarded-Proto'];
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
function cdpPath(rest) {
|
|
20
24
|
if (rest === '/json' || rest === '/json/list') {
|
|
21
25
|
return '/json/list';
|
|
@@ -39,7 +43,7 @@ function queryOf(url) {
|
|
|
39
43
|
|
|
40
44
|
async function sendJson(cdp, target, req, res, nodeId) {
|
|
41
45
|
const path = cdpPath(inspectUrl(req.url).path);
|
|
42
|
-
const body = await cdp.json(target, path, hostOf(req), nodeId);
|
|
46
|
+
const body = await cdp.json(target, path, hostOf(req), nodeId, protoOf(req));
|
|
43
47
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
44
48
|
res.end(body);
|
|
45
49
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import positions from './graphLayout.js';
|
|
2
|
+
|
|
1
3
|
const SVG = 'http://www.w3.org/2000/svg';
|
|
2
4
|
|
|
3
5
|
function shops(nodes) {
|
|
@@ -6,31 +8,11 @@ function shops(nodes) {
|
|
|
6
8
|
});
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
return
|
|
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 };
|
|
11
|
+
function floor(placed) {
|
|
12
|
+
const ys = Object.values(placed).map((item) => {
|
|
13
|
+
return item.y + 80;
|
|
32
14
|
});
|
|
33
|
-
return
|
|
15
|
+
return Math.max(520, ...ys);
|
|
34
16
|
}
|
|
35
17
|
|
|
36
18
|
function el(name, attrs) {
|
|
@@ -41,10 +23,10 @@ function el(name, attrs) {
|
|
|
41
23
|
return node;
|
|
42
24
|
}
|
|
43
25
|
|
|
44
|
-
function paintLink(svg, graph,
|
|
26
|
+
function paintLink(svg, graph, laid) {
|
|
45
27
|
graph.links.forEach((link) => {
|
|
46
|
-
const from =
|
|
47
|
-
const to =
|
|
28
|
+
const from = laid[link.from];
|
|
29
|
+
const to = laid[link.to];
|
|
48
30
|
if (!from || !to) {
|
|
49
31
|
return;
|
|
50
32
|
}
|
|
@@ -95,12 +77,12 @@ export default function draw(svg, graph, onClick) {
|
|
|
95
77
|
svg.removeChild(svg.firstChild);
|
|
96
78
|
}
|
|
97
79
|
const columns = Math.max(shops(graph.nodes).length, 1);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
paintLink(svg, graph,
|
|
80
|
+
const laid = positions(graph);
|
|
81
|
+
svg.setAttribute('viewBox', `0 0 ${Math.max(960, columns * 280 + 120)} ${floor(laid)}`);
|
|
82
|
+
paintLink(svg, graph, laid);
|
|
101
83
|
graph.nodes.forEach((node) => {
|
|
102
|
-
if (
|
|
103
|
-
paintNode(svg, node,
|
|
84
|
+
if (laid[node.id]) {
|
|
85
|
+
paintNode(svg, node, laid[node.id], onClick);
|
|
104
86
|
}
|
|
105
87
|
});
|
|
106
88
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function shops(nodes) {
|
|
2
|
+
return nodes.filter((node) => {
|
|
3
|
+
return node.kind === 'shop';
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function children(nodes, parentId, kind) {
|
|
8
|
+
return nodes.filter((node) => {
|
|
9
|
+
return node.parent === parentId && node.kind === kind;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function nest(nodes, item, x, y, placed) {
|
|
14
|
+
children(nodes, item.id, 'collector').forEach((col, k) => {
|
|
15
|
+
placed[col.id] = { x: x + 96, y: y + k * 26 };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function machines(nodes, area, x, base, placed) {
|
|
20
|
+
children(nodes, area.id, 'machine').forEach((item, j) => {
|
|
21
|
+
const y = base + j * 96;
|
|
22
|
+
placed[item.id] = { x, y };
|
|
23
|
+
nest(nodes, item, x, y, placed);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extras(nodes, area, x, placed) {
|
|
28
|
+
const items = children(nodes, area.id, 'collector');
|
|
29
|
+
items.forEach((col, k) => {
|
|
30
|
+
placed[col.id] = { x, y: 108 + k * 28 };
|
|
31
|
+
});
|
|
32
|
+
return items.length;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function column(area, index, nodes, placed) {
|
|
36
|
+
const x = 170 + index * 280;
|
|
37
|
+
placed[area.id] = { x, y: 56 };
|
|
38
|
+
const count = extras(nodes, area, x, placed);
|
|
39
|
+
const base = count === 0 ? 150 : 108 + count * 28 + 50;
|
|
40
|
+
machines(nodes, area, x, base, placed);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function leftovers(nodes, placed) {
|
|
44
|
+
nodes.filter((node) => {
|
|
45
|
+
return node.kind === 'collector' && !placed[node.id];
|
|
46
|
+
}).forEach((node, i) => {
|
|
47
|
+
placed[node.id] = { x: 72, y: 88 + i * 54 };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Column positions for topology nodes by parent and kind.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} graph - topology contract { nodes }
|
|
55
|
+
* @returns {object} id -> { x, y }
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* positions({ nodes: [{ id: 'area-1', kind: 'shop' }] })['area-1'].x === 170;
|
|
59
|
+
*/
|
|
60
|
+
export default function positions(graph) {
|
|
61
|
+
const nodes = graph.nodes || [];
|
|
62
|
+
const placed = {};
|
|
63
|
+
shops(nodes).forEach((area, i) => {
|
|
64
|
+
column(area, i, nodes, placed);
|
|
65
|
+
});
|
|
66
|
+
leftovers(nodes, placed);
|
|
67
|
+
return placed;
|
|
68
|
+
}
|