@yarkivaev/scada 2.3.61 → 2.3.63
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 +1 -0
- package/package.json +2 -2
- package/src/application/plantApi.js +6 -0
- package/src/application/plantServer.js +4 -0
- package/src/application/sync/siteSync.js +37 -7
- package/src/application/sync/siteSyncBind.js +10 -2
- package/src/application/sync/siteSyncRows.js +3 -0
- package/src/application/topologyFromPlant.js +85 -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/syncRoute.js +8 -8
- package/src/infrastructure/http/plant/routes/topologyRoute.js +20 -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. `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` | 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,7 @@ 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';
|
|
17
18
|
export { default as tagCatalog } from './src/infrastructure/catalog/tagCatalog.js';
|
|
18
19
|
export { default as machineInPlant } from './src/application/machineInPlant.js';
|
|
19
20
|
export { default as shopWithTimeline } from './src/application/shopWithTimeline.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.63",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clickhouse/client": "^1.0.0",
|
|
31
|
-
"@yarkivaev/simple-server": "1.0.
|
|
31
|
+
"@yarkivaev/simple-server": "1.0.2",
|
|
32
32
|
"@yarkivaev/source-to-sink": "1.3.4",
|
|
33
33
|
"amqplib": "^0.10.9",
|
|
34
34
|
"pg": "^8.0.0",
|
|
@@ -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),
|
|
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
|
});
|
|
@@ -5,7 +5,7 @@ import { siteById } from './siteSyncSites.js';
|
|
|
5
5
|
* Pulls selected kinds from a remote plantApi into local targets.
|
|
6
6
|
*
|
|
7
7
|
* @param {object} ports - { sites, queryFor, targets }
|
|
8
|
-
* @returns {object} frozen sync with run(request)
|
|
8
|
+
* @returns {object} frozen sync with run(request, ports)
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* const sync = siteSync({ sites, queryFor, targets });
|
|
@@ -75,19 +75,45 @@ function writeKind(query, targets, job, range) {
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function
|
|
78
|
+
function stopped() {
|
|
79
|
+
const error = new Error('job stopped');
|
|
80
|
+
error.code = 'STOPPED';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ignore(progress) {
|
|
85
|
+
return progress;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extrasOf(ports) {
|
|
89
|
+
return {
|
|
90
|
+
signal: ports && ports.signal ? ports.signal : { aborted: false },
|
|
91
|
+
report: ports && ports.report ? ports.report : ignore
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function step(query, targets, job, ctx) {
|
|
96
|
+
if (ctx.extras.signal.aborted) {
|
|
97
|
+
stopped();
|
|
98
|
+
}
|
|
99
|
+
return writeKind(query, targets, job, ctx.range).then((added) => {
|
|
100
|
+
const next = add(ctx.counts, job.kind, added);
|
|
101
|
+
ctx.extras.report({ machine: job.machine, kind: job.kind, counts: next });
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fill(query, targets, kinds, machines, ctx) {
|
|
79
107
|
return jobsOf(machines, kinds).reduce((chain, job) => {
|
|
80
108
|
return chain.then((counts) => {
|
|
81
|
-
return
|
|
82
|
-
return add(counts, job.kind, added);
|
|
83
|
-
});
|
|
109
|
+
return step(query, targets, job, { ...ctx, counts });
|
|
84
110
|
});
|
|
85
111
|
}, Promise.resolve(zero(kinds)));
|
|
86
112
|
}
|
|
87
113
|
|
|
88
114
|
export default function siteSync(ports) {
|
|
89
115
|
return Object.freeze({
|
|
90
|
-
async run(request) {
|
|
116
|
+
async run(request, extras) {
|
|
91
117
|
if (!request.from || !request.to) {
|
|
92
118
|
throw new Error('site sync requires from and to');
|
|
93
119
|
}
|
|
@@ -95,7 +121,11 @@ export default function siteSync(ports) {
|
|
|
95
121
|
const query = ports.queryFor(site);
|
|
96
122
|
const kinds = kindsOf(request, ports.targets);
|
|
97
123
|
const machines = await machinesOf(request, site, query);
|
|
98
|
-
const
|
|
124
|
+
const range = { from: request.from, to: request.to };
|
|
125
|
+
const counts = await fill(query, ports.targets, kinds, machines, {
|
|
126
|
+
range,
|
|
127
|
+
extras: extrasOf(extras)
|
|
128
|
+
});
|
|
99
129
|
return { site: site.id, machines, counts };
|
|
100
130
|
}
|
|
101
131
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clickhouseSink } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import { jobRoutes, jobs } from '@yarkivaev/simple-server';
|
|
2
3
|
import exportQuery from '../export/exportQuery.js';
|
|
3
4
|
import scadaClient from '../../infrastructure/client/scadaClient.js';
|
|
4
5
|
import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
|
|
@@ -13,7 +14,7 @@ import syncRoute from '../../infrastructure/http/plant/routes/syncRoute.js';
|
|
|
13
14
|
* @param {object} env - process environment
|
|
14
15
|
* @param {object} sink - supervisor sink with pool and dataAccess
|
|
15
16
|
* @param {object} operations - operations port
|
|
16
|
-
* @param {object} [extras] - topic(machine, key), basePath
|
|
17
|
+
* @param {object} [extras] - topic(machine, key), basePath, board
|
|
17
18
|
* @returns {object[]} plant extra routes, empty when no sites are configured
|
|
18
19
|
*
|
|
19
20
|
* @example
|
|
@@ -60,5 +61,12 @@ export default function siteSyncBind(env, sink, operations, extras) {
|
|
|
60
61
|
topic: extra.topic
|
|
61
62
|
});
|
|
62
63
|
const sync = siteSync({ sites, queryFor, targets });
|
|
63
|
-
|
|
64
|
+
const board = extra.board || jobs(() => {
|
|
65
|
+
return new Date();
|
|
66
|
+
});
|
|
67
|
+
const base = extra.basePath || '/api/v1';
|
|
68
|
+
return [
|
|
69
|
+
...syncRoute(base, sync, env.SYNC_TOKEN, board),
|
|
70
|
+
...jobRoutes(base, board)
|
|
71
|
+
];
|
|
64
72
|
}
|
|
@@ -56,6 +56,9 @@ export function measurementRows(machine, body, topic) {
|
|
|
56
56
|
const items = body.items || [];
|
|
57
57
|
return items.flatMap((series) => {
|
|
58
58
|
const dest = topic(machine, series.key);
|
|
59
|
+
if (!dest) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
59
62
|
return (series.values || []).map((point) => {
|
|
60
63
|
return { topic: dest, ts: point.timestamp, value: point.value };
|
|
61
64
|
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,12 +4,13 @@ import { errorResponse, jsonResponse, readBody, route } from '@yarkivaev/simple-
|
|
|
4
4
|
* POST /sync — pull selected kinds from an allowlisted remote plantApi.
|
|
5
5
|
*
|
|
6
6
|
* @param {string} basePath - API prefix
|
|
7
|
-
* @param {object} sync - siteSync port with run(request)
|
|
7
|
+
* @param {object} sync - siteSync port with run(request, ports)
|
|
8
8
|
* @param {string} [token] - optional Bearer token
|
|
9
|
+
* @param {object} board - jobs board
|
|
9
10
|
* @returns {object[]} routes
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
|
-
* syncRoute('/api/v1', sync, process.env.SYNC_TOKEN);
|
|
13
|
+
* syncRoute('/api/v1', sync, process.env.SYNC_TOKEN, board);
|
|
13
14
|
*/
|
|
14
15
|
function rejectAuth(token, req, res) {
|
|
15
16
|
if (!token) {
|
|
@@ -30,7 +31,7 @@ function rejectScope(parsed, res) {
|
|
|
30
31
|
return true;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export default function syncRoute(basePath, sync, token) {
|
|
34
|
+
export default function syncRoute(basePath, sync, token, board) {
|
|
34
35
|
return [
|
|
35
36
|
route('POST', `${basePath}/sync`, async (req, res) => {
|
|
36
37
|
if (rejectAuth(token, req, res)) {
|
|
@@ -40,11 +41,10 @@ export default function syncRoute(basePath, sync, token) {
|
|
|
40
41
|
if (rejectScope(parsed, res)) {
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
}
|
|
44
|
+
const { id } = board.start(({ signal, report }) => {
|
|
45
|
+
return sync.run(parsed, { signal, report });
|
|
46
|
+
});
|
|
47
|
+
jsonResponse({ id }, 202).send(res);
|
|
48
48
|
})
|
|
49
49
|
];
|
|
50
50
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
2
|
+
import topologyFromPlant from '../../../../application/topologyFromPlant.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plant topology graph route.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} basePath - API base path
|
|
8
|
+
* @param {object} plant - plant domain object
|
|
9
|
+
* @returns {array} route objects
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* topologyRoute('/api/v1', plant);
|
|
13
|
+
*/
|
|
14
|
+
export default function topologyRoute(basePath, plant) {
|
|
15
|
+
return [
|
|
16
|
+
route('GET', `${basePath}/topology`, (req, res) => {
|
|
17
|
+
jsonResponse(topologyFromPlant(plant)).send(res);
|
|
18
|
+
})
|
|
19
|
+
];
|
|
20
|
+
}
|
|
@@ -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>
|