@yarkivaev/scada 2.3.65 → 2.3.67
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/inspectRoute.js +5 -1
- package/src/infrastructure/http/plant/ui/infra/app.css +1 -0
- package/src/infrastructure/http/plant/ui/infra/graphLayout.js +8 -5
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -26,18 +26,21 @@ function machines(nodes, area, x, base, placed) {
|
|
|
26
26
|
|
|
27
27
|
function extras(nodes, area, x, placed) {
|
|
28
28
|
const items = children(nodes, area.id, 'collector');
|
|
29
|
+
if (items.length === 0) {
|
|
30
|
+
return 150;
|
|
31
|
+
}
|
|
32
|
+
const pitch = 64;
|
|
33
|
+
const head = 124;
|
|
29
34
|
items.forEach((col, k) => {
|
|
30
|
-
placed[col.id] = { x, y:
|
|
35
|
+
placed[col.id] = { x, y: head + k * pitch };
|
|
31
36
|
});
|
|
32
|
-
return items.length;
|
|
37
|
+
return head + items.length * pitch + 48;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
function column(area, index, nodes, placed) {
|
|
36
41
|
const x = 170 + index * 280;
|
|
37
42
|
placed[area.id] = { x, y: 56 };
|
|
38
|
-
|
|
39
|
-
const base = count === 0 ? 150 : 108 + count * 28 + 50;
|
|
40
|
-
machines(nodes, area, x, base, placed);
|
|
43
|
+
machines(nodes, area, x, extras(nodes, area, x, placed), placed);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function leftovers(nodes, placed) {
|