@yarkivaev/scada 2.3.87 → 2.3.88
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 +7 -4
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/application/mcp/analyticsMcp.js +26 -20
- package/src/application/mcp/mcpCoverage.js +3 -2
- package/src/application/mcp/mcpFlight.js +42 -0
- package/src/application/mcp/mcpObserve.js +29 -3
- package/src/application/mcp/mcpResources.js +56 -14
- package/src/application/mcp/mcpSchema.js +98 -0
- package/src/domain/span/coverageOverlay.js +164 -0
package/README.md
CHANGED
|
@@ -4,13 +4,16 @@ Generic industrial monitoring domain, state persistence, and HTTP APIs (`@yarkiv
|
|
|
4
4
|
|
|
5
5
|
This package is plant-agnostic: it defines `plant` → `shop` → `machine` hierarchy, timeline and alerts, sensor read ports, STOMP ingest pipelines, and PostgreSQL/ClickHouse adapters. Plant-specific routes, sensors, HMI strings, and tag taxonomies live in downstream plant packages.
|
|
6
6
|
|
|
7
|
-
`analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic)
|
|
7
|
+
`analyticsMcp` is a stateless Streamable HTTP MCP server. The plant package supplies a `namespaceTree` (path, kind, silence, source topic), read ports, and the tag catalog already used by `plantApi({ tagCatalog })`. Tools address nodes by path. `query_postgres` is registered only when a standby query port is supplied. Each SQL engine runs one query at a time. Each call writes one JSON line to the injected journal. `GET /metrics` exposes call counts and in-flight reads.
|
|
8
|
+
|
|
9
|
+
Resources: `plant://topology`, `plant://bindings`, `plant://tags`, `plant://schema/postgres`, `plant://schema/clickhouse`. Column lists live in the schema resources, not here.
|
|
8
10
|
|
|
9
11
|
```javascript
|
|
10
|
-
import { analyticsMcp, namespaceTree } from '@yarkivaev/scada';
|
|
12
|
+
import { analyticsMcp, namespaceTree, tagCatalog } from '@yarkivaev/scada';
|
|
11
13
|
|
|
12
14
|
const mcp = analyticsMcp({
|
|
13
15
|
tree: namespaceTree(nodes),
|
|
16
|
+
catalog: tagCatalog(),
|
|
14
17
|
tokens: { secret: 'analyst' },
|
|
15
18
|
journal(line) { process.stdout.write(line); },
|
|
16
19
|
ports: {
|
|
@@ -21,7 +24,7 @@ const mcp = analyticsMcp({
|
|
|
21
24
|
await mcp.listen(0, '127.0.0.1');
|
|
22
25
|
```
|
|
23
26
|
|
|
24
|
-
`ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`.
|
|
27
|
+
`ports.queryPostgres` registers `query_postgres`. `extend(server)` may add plant tools. `POST /mcp` requires `Authorization: Bearer`. Coverage spans add the overlapping timeline segment name as `phase`. The process journal is stdout only; `activityTracking` still writes Loki HTTP access into `activity.http_access`, not `activity.mcp_calls`.
|
|
25
28
|
|
|
26
29
|
Repository: `https://github.com/yarkivaev/scada`
|
|
27
30
|
|
|
@@ -132,7 +135,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
132
135
|
| `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
|
|
133
136
|
| `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
|
|
134
137
|
| `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
|
|
135
|
-
| `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, checkpoint-or-sample cursor, read-only SQL guard |
|
|
138
|
+
| `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageOverlay`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, timeline-name overlay, checkpoint-or-sample cursor, read-only SQL guard |
|
|
136
139
|
|
|
137
140
|
Subpath exports:
|
|
138
141
|
|
package/index.js
CHANGED
|
@@ -87,6 +87,7 @@ export { default as siteSyncSites } from './src/application/sync/siteSyncSites.j
|
|
|
87
87
|
export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
|
|
88
88
|
export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
|
|
89
89
|
export { default as namespaceTree } from './src/domain/namespace/namespaceTree.js';
|
|
90
|
+
export { default as coverageOverlay } from './src/domain/span/coverageOverlay.js';
|
|
90
91
|
export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
|
|
91
92
|
export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
|
|
92
93
|
export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import mcpFlight from './mcpFlight.js';
|
|
5
6
|
import mcpMetrics from './mcpMetrics.js';
|
|
6
7
|
import observe from './mcpObserve.js';
|
|
7
8
|
import registerCoverage from './mcpCoverage.js';
|
|
@@ -98,20 +99,25 @@ function extendServer(server, bundle) {
|
|
|
98
99
|
* Builds one stateless server for a single HTTP request.
|
|
99
100
|
*
|
|
100
101
|
* @param {object} bundle - analyticsMcp bundle
|
|
101
|
-
* @param {object}
|
|
102
|
+
* @param {object} runtime - metrics and SQL flight
|
|
102
103
|
* @param {string} caller - bearer caller name
|
|
103
104
|
* @returns {object} McpServer
|
|
104
105
|
*
|
|
105
106
|
* @example
|
|
106
|
-
* openServer(bundle,
|
|
107
|
+
* openServer(bundle, runtime, 'analyst');
|
|
107
108
|
*/
|
|
108
|
-
function openServer(bundle,
|
|
109
|
+
function openServer(bundle, runtime, caller) {
|
|
109
110
|
const server = new McpServer(serverInfo());
|
|
110
|
-
observe(server, {
|
|
111
|
+
observe(server, {
|
|
112
|
+
caller,
|
|
113
|
+
flight: runtime.flight,
|
|
114
|
+
journal: bundle.journal,
|
|
115
|
+
metrics: runtime.metrics
|
|
116
|
+
});
|
|
111
117
|
registerReads(server, bundle.ports, bundle.tree);
|
|
112
118
|
registerCoverage(server, bundle.ports, bundle.tree);
|
|
113
119
|
registerQueries(server, bundle.ports);
|
|
114
|
-
registerResources(server, bundle.tree);
|
|
120
|
+
registerResources(server, bundle.tree, bundle.catalog);
|
|
115
121
|
extendServer(server, bundle);
|
|
116
122
|
return server;
|
|
117
123
|
}
|
|
@@ -183,8 +189,8 @@ function releaser(transport, server) {
|
|
|
183
189
|
* @example
|
|
184
190
|
* dispatch(req, res, bundle, metrics, 'analyst');
|
|
185
191
|
*/
|
|
186
|
-
async function dispatch(req, res, bundle,
|
|
187
|
-
const server = openServer(bundle,
|
|
192
|
+
async function dispatch(req, res, bundle, runtime, caller) {
|
|
193
|
+
const server = openServer(bundle, runtime, caller);
|
|
188
194
|
const transport = new StreamableHTTPServerTransport({
|
|
189
195
|
enableJsonResponse: true,
|
|
190
196
|
sessionIdGenerator: undefined
|
|
@@ -214,13 +220,13 @@ async function dispatch(req, res, bundle, metrics, caller) {
|
|
|
214
220
|
* @example
|
|
215
221
|
* postMcp(req, res, bundle, metrics);
|
|
216
222
|
*/
|
|
217
|
-
async function postMcp(req, res, bundle,
|
|
223
|
+
async function postMcp(req, res, bundle, runtime) {
|
|
218
224
|
const name = callerName(req, bundle.tokens);
|
|
219
225
|
if (name.length === 0) {
|
|
220
226
|
res.writeHead(401).end();
|
|
221
227
|
return;
|
|
222
228
|
}
|
|
223
|
-
await dispatch(req, res, bundle,
|
|
229
|
+
await dispatch(req, res, bundle, runtime, name);
|
|
224
230
|
}
|
|
225
231
|
|
|
226
232
|
/**
|
|
@@ -233,9 +239,9 @@ async function postMcp(req, res, bundle, metrics) {
|
|
|
233
239
|
* @example
|
|
234
240
|
* writeMetrics(res, metrics);
|
|
235
241
|
*/
|
|
236
|
-
function writeMetrics(res,
|
|
242
|
+
function writeMetrics(res, runtime) {
|
|
237
243
|
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
|
238
|
-
res.end(metrics.text());
|
|
244
|
+
res.end(runtime.metrics.text());
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
@@ -250,13 +256,13 @@ function writeMetrics(res, metrics) {
|
|
|
250
256
|
* @example
|
|
251
257
|
* route(req, res, bundle, metrics);
|
|
252
258
|
*/
|
|
253
|
-
function route(req, res, bundle,
|
|
259
|
+
function route(req, res, bundle, runtime) {
|
|
254
260
|
if (req.method === 'GET' && req.url === '/metrics') {
|
|
255
|
-
writeMetrics(res,
|
|
261
|
+
writeMetrics(res, runtime);
|
|
256
262
|
return Promise.resolve();
|
|
257
263
|
}
|
|
258
264
|
if (req.method === 'POST' && req.url === '/mcp') {
|
|
259
|
-
return postMcp(req, res, bundle,
|
|
265
|
+
return postMcp(req, res, bundle, runtime);
|
|
260
266
|
}
|
|
261
267
|
res.writeHead(404).end();
|
|
262
268
|
return Promise.resolve();
|
|
@@ -291,8 +297,8 @@ function fail(res) {
|
|
|
291
297
|
* @example
|
|
292
298
|
* onRequest(req, res, bundle, metrics);
|
|
293
299
|
*/
|
|
294
|
-
function onRequest(req, res, bundle,
|
|
295
|
-
route(req, res, bundle,
|
|
300
|
+
function onRequest(req, res, bundle, runtime) {
|
|
301
|
+
route(req, res, bundle, runtime).catch(() => {
|
|
296
302
|
fail(res);
|
|
297
303
|
});
|
|
298
304
|
}
|
|
@@ -352,18 +358,18 @@ function shut(server) {
|
|
|
352
358
|
* is a function. extend(server) may register more tools. POST /mcp speaks MCP.
|
|
353
359
|
* GET /metrics is Prometheus text.
|
|
354
360
|
*
|
|
355
|
-
* @param {object} bundle - tree, tokens, journal, ports, optional extend
|
|
361
|
+
* @param {object} bundle - tree, tokens, journal, ports, optional catalog and extend
|
|
356
362
|
* @returns {object} listen and close
|
|
357
363
|
*
|
|
358
364
|
* @example
|
|
359
|
-
* const mcp = analyticsMcp({ tree, tokens, journal, ports });
|
|
365
|
+
* const mcp = analyticsMcp({ tree, tokens, journal, ports, catalog });
|
|
360
366
|
* await mcp.listen(0, '127.0.0.1');
|
|
361
367
|
*/
|
|
362
368
|
export default function analyticsMcp(bundle) {
|
|
363
369
|
assertBundle(bundle);
|
|
364
|
-
const
|
|
370
|
+
const runtime = { flight: mcpFlight(), metrics: mcpMetrics() };
|
|
365
371
|
const server = http.createServer((req, res) => {
|
|
366
|
-
onRequest(req, res, bundle,
|
|
372
|
+
onRequest(req, res, bundle, runtime);
|
|
367
373
|
});
|
|
368
374
|
return Object.freeze({
|
|
369
375
|
close() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import coverageOverlay from '../../domain/span/coverageOverlay.js';
|
|
2
3
|
import coverageSpans from '../../domain/span/coverageSpans.js';
|
|
3
4
|
import { toolText } from './mcpText.js';
|
|
4
5
|
|
|
@@ -34,13 +35,13 @@ async function coverOne(path, args, ports, tree) {
|
|
|
34
35
|
const node = tree.resolve(path);
|
|
35
36
|
const samples = await ports.samples({ from: args.from, path, to: args.to });
|
|
36
37
|
const cursor = await ports.cursor({ path });
|
|
37
|
-
const spans = coverageSpans(
|
|
38
|
+
const spans = coverageOverlay(coverageSpans(
|
|
38
39
|
samples,
|
|
39
40
|
args.from,
|
|
40
41
|
args.to,
|
|
41
42
|
node.silenceSeconds * 1000,
|
|
42
43
|
cursorAt(cursor)
|
|
43
|
-
);
|
|
44
|
+
), await ports.segments({ from: args.from, path, to: args.to }));
|
|
44
45
|
return {
|
|
45
46
|
path,
|
|
46
47
|
spans: spans.slice(0, 200),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One in-flight callback per SQL engine.
|
|
3
|
+
*
|
|
4
|
+
* ClickHouse and Postgres each keep a tail promise. A later job waits for
|
|
5
|
+
* that tail before it runs, so two query tools never execute together.
|
|
6
|
+
*
|
|
7
|
+
* @returns {object} run(engine, job)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const flight = mcpFlight();
|
|
11
|
+
* await flight.run('clickhouse', () => query('SELECT 1'));
|
|
12
|
+
*/
|
|
13
|
+
export default function mcpFlight() {
|
|
14
|
+
const tail = {
|
|
15
|
+
clickhouse: Promise.resolve(),
|
|
16
|
+
postgres: Promise.resolve()
|
|
17
|
+
};
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
/**
|
|
20
|
+
* Runs job after the previous job for this engine settles.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} engine - clickhouse or postgres
|
|
23
|
+
* @param {Function} job - callback
|
|
24
|
+
* @returns {Promise<*>} job result
|
|
25
|
+
*/
|
|
26
|
+
run(engine, job) {
|
|
27
|
+
const prior = tail[engine];
|
|
28
|
+
let release;
|
|
29
|
+
const held = new Promise((resolve) => {
|
|
30
|
+
release = resolve;
|
|
31
|
+
});
|
|
32
|
+
tail[engine] = prior.then(() => {
|
|
33
|
+
return held;
|
|
34
|
+
}, () => {
|
|
35
|
+
return held;
|
|
36
|
+
});
|
|
37
|
+
return prior.then(job, job).finally(() => {
|
|
38
|
+
release();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -72,7 +72,7 @@ function settle(outcome) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Times one tool call
|
|
75
|
+
* Times one tool call after the engine slot is held.
|
|
76
76
|
*
|
|
77
77
|
* @param {string} name - tool name
|
|
78
78
|
* @param {object} args - tool arguments
|
|
@@ -82,9 +82,9 @@ function settle(outcome) {
|
|
|
82
82
|
* @returns {Promise<object>} tool result
|
|
83
83
|
*
|
|
84
84
|
* @example
|
|
85
|
-
*
|
|
85
|
+
* invokeTool('state', {}, {}, () => ({ content: [] }), ctx);
|
|
86
86
|
*/
|
|
87
|
-
async function
|
|
87
|
+
async function invokeTool(name, args, extra, cb, ctx) {
|
|
88
88
|
const engine = engineOf(name);
|
|
89
89
|
const started = Date.now();
|
|
90
90
|
ctx.metrics.begin(engine);
|
|
@@ -94,6 +94,32 @@ async function trackedTool(name, args, extra, cb, ctx) {
|
|
|
94
94
|
return settle(outcome);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Times one tool call, counts it, and tracks the engine gauge.
|
|
99
|
+
*
|
|
100
|
+
* SQL engines wait for the previous query on that engine so the in-flight
|
|
101
|
+
* gauge stays at one. Named plant tools are not queued.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} name - tool name
|
|
104
|
+
* @param {object} args - tool arguments
|
|
105
|
+
* @param {object} extra - SDK extra
|
|
106
|
+
* @param {Function} cb - tool callback
|
|
107
|
+
* @param {object} ctx - journal context
|
|
108
|
+
* @returns {Promise<object>} tool result
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* trackedTool('state', {}, {}, () => ({ content: [] }), ctx);
|
|
112
|
+
*/
|
|
113
|
+
function trackedTool(name, args, extra, cb, ctx) {
|
|
114
|
+
const engine = engineOf(name);
|
|
115
|
+
if (engine === 'plant') {
|
|
116
|
+
return invokeTool(name, args, extra, cb, ctx);
|
|
117
|
+
}
|
|
118
|
+
return ctx.flight.run(engine, () => {
|
|
119
|
+
return invokeTool(name, args, extra, cb, ctx);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
/**
|
|
98
124
|
* Times one resource read.
|
|
99
125
|
*
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { clickhouseSchema, postgresSchema } from './mcpSchema.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Parent path of a node, or an empty string at the root.
|
|
3
5
|
*
|
|
@@ -78,29 +80,69 @@ function body(uri, payload) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
*
|
|
83
|
+
* Enumerates catalog entries without knowing their vocabulary.
|
|
82
84
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
+
* @param {object} [catalog] - tagCatalog with entries()
|
|
86
|
+
* @returns {Array<object>} entries
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* tags({ entries() { return []; } });
|
|
90
|
+
*/
|
|
91
|
+
function tags(catalog) {
|
|
92
|
+
if (catalog && typeof catalog.entries === 'function') {
|
|
93
|
+
return catalog.entries();
|
|
94
|
+
}
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Registers one JSON resource.
|
|
85
100
|
*
|
|
86
101
|
* @param {object} server - McpServer
|
|
87
|
-
* @param {
|
|
102
|
+
* @param {string} name - resource name
|
|
103
|
+
* @param {string} uri - resource URI
|
|
104
|
+
* @param {string} description - short list description
|
|
105
|
+
* @param {Function} payload - body factory
|
|
88
106
|
* @returns {void}
|
|
89
107
|
*
|
|
90
108
|
* @example
|
|
91
|
-
*
|
|
109
|
+
* publish(server, 'topology', 'plant://topology', 'Paths', () => []);
|
|
92
110
|
*/
|
|
93
|
-
|
|
94
|
-
server.registerResource(
|
|
95
|
-
description
|
|
111
|
+
function publish(server, name, uri, description, payload) {
|
|
112
|
+
server.registerResource(name, uri, {
|
|
113
|
+
description,
|
|
96
114
|
mimeType: 'application/json'
|
|
97
115
|
}, () => {
|
|
98
|
-
return body(
|
|
116
|
+
return body(uri, payload());
|
|
99
117
|
});
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Registers plant topology, bindings, tags, and storage schemas.
|
|
122
|
+
*
|
|
123
|
+
* Topology has paths, kinds, titles, and liveness. Bindings map a path to a
|
|
124
|
+
* topic and a unit. Tags enumerate the catalog the plant package supplied.
|
|
125
|
+
* Schema resources list scada migration tables. Gateway topics stay out of
|
|
126
|
+
* topology. Operators omit card_uid. kind is opaque.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} server - McpServer
|
|
129
|
+
* @param {object} tree - namespaceTree
|
|
130
|
+
* @param {object} [catalog] - tagCatalog with entries()
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* registerResources(server, tree, catalog);
|
|
135
|
+
*/
|
|
136
|
+
export default function registerResources(server, tree, catalog) {
|
|
137
|
+
publish(server, 'topology', 'plant://topology', 'Paths, kinds, titles, and liveness', () => {
|
|
138
|
+
return topology(tree);
|
|
139
|
+
});
|
|
140
|
+
publish(server, 'bindings', 'plant://bindings', 'Source topic and unit for each bound path', () => {
|
|
141
|
+
return bindings(tree);
|
|
142
|
+
});
|
|
143
|
+
publish(server, 'tags', 'plant://tags', 'Tag catalog entries from the plant package', () => {
|
|
144
|
+
return tags(catalog);
|
|
105
145
|
});
|
|
146
|
+
publish(server, 'schema-postgres', 'plant://schema/postgres', 'Postgres tables from scada migrations', postgresSchema);
|
|
147
|
+
publish(server, 'schema-clickhouse', 'plant://schema/clickhouse', 'ClickHouse scada.metrics', clickhouseSchema);
|
|
106
148
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column list for one schema table.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} name - table name
|
|
5
|
+
* @param {Array<object>} columns - name and type
|
|
6
|
+
* @param {Array<string>} [notes] - short notes
|
|
7
|
+
* @returns {object} table
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* table('alerts', [{ name: 'id', type: 'integer' }]);
|
|
11
|
+
*/
|
|
12
|
+
function table(name, columns, notes) {
|
|
13
|
+
if (!notes) {
|
|
14
|
+
return { columns, name };
|
|
15
|
+
}
|
|
16
|
+
return { columns, name, notes };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Postgres tables from scada migrations, without operator credentials.
|
|
21
|
+
*
|
|
22
|
+
* kind is an opaque string. Plant brigade tables are not listed.
|
|
23
|
+
*
|
|
24
|
+
* @returns {object} schema body
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* postgresSchema().tables[0].name;
|
|
28
|
+
*/
|
|
29
|
+
export function postgresSchema() {
|
|
30
|
+
return {
|
|
31
|
+
tables: [
|
|
32
|
+
table('segments', [
|
|
33
|
+
{ name: 'machine', type: 'text' },
|
|
34
|
+
{ name: 'name', type: 'text' },
|
|
35
|
+
{ name: 'start_time', type: 'timestamptz' },
|
|
36
|
+
{ name: 'end_time', type: 'timestamptz' },
|
|
37
|
+
{ name: 'duration', type: 'float' },
|
|
38
|
+
{ name: 'options', type: 'text' },
|
|
39
|
+
{ name: 'tags', type: 'text' },
|
|
40
|
+
{ name: 'properties', type: 'text' },
|
|
41
|
+
{ name: 'resolved', type: 'boolean' },
|
|
42
|
+
{ name: 'consumed', type: 'boolean' },
|
|
43
|
+
{ name: 'kind', type: 'text' },
|
|
44
|
+
{ name: 'updated_at', type: 'timestamptz' }
|
|
45
|
+
], ['kind is an opaque string']),
|
|
46
|
+
table('operations', [
|
|
47
|
+
{ name: 'machine', type: 'text' },
|
|
48
|
+
{ name: 'occurred_at', type: 'timestamptz' },
|
|
49
|
+
{ name: 'kind', type: 'text' },
|
|
50
|
+
{ name: 'key', type: 'text' },
|
|
51
|
+
{ name: 'payload', type: 'jsonb' }
|
|
52
|
+
], ['kind is an opaque string']),
|
|
53
|
+
table('alerts', [
|
|
54
|
+
{ name: 'id', type: 'integer' },
|
|
55
|
+
{ name: 'name', type: 'text' },
|
|
56
|
+
{ name: 'message', type: 'text' },
|
|
57
|
+
{ name: 'machine', type: 'text' },
|
|
58
|
+
{ name: 'severity', type: 'text' },
|
|
59
|
+
{ name: 'timestamp', type: 'timestamptz' },
|
|
60
|
+
{ name: 'acknowledged', type: 'boolean' }
|
|
61
|
+
]),
|
|
62
|
+
table('user_decisions', [
|
|
63
|
+
{ name: 'machine', type: 'text' },
|
|
64
|
+
{ name: 'start_time', type: 'timestamptz' },
|
|
65
|
+
{ name: 'username', type: 'text' },
|
|
66
|
+
{ name: 'payload', type: 'text' },
|
|
67
|
+
{ name: 'operator_id', type: 'integer' },
|
|
68
|
+
{ name: 'decided_at', type: 'timestamptz' }
|
|
69
|
+
]),
|
|
70
|
+
table('operators', [
|
|
71
|
+
{ name: 'id', type: 'integer' },
|
|
72
|
+
{ name: 'first_name', type: 'text' },
|
|
73
|
+
{ name: 'last_name', type: 'text' },
|
|
74
|
+
{ name: 'display_name', type: 'text' }
|
|
75
|
+
])
|
|
76
|
+
]
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ClickHouse metrics table. topic is the only series key.
|
|
82
|
+
*
|
|
83
|
+
* @returns {object} schema body
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* clickhouseSchema().tables[0].name;
|
|
87
|
+
*/
|
|
88
|
+
export function clickhouseSchema() {
|
|
89
|
+
return {
|
|
90
|
+
tables: [
|
|
91
|
+
table('scada.metrics', [
|
|
92
|
+
{ name: 'topic', type: 'String' },
|
|
93
|
+
{ name: 'ts', type: 'DateTime64(3)' },
|
|
94
|
+
{ name: 'value', type: 'Float64' }
|
|
95
|
+
], ['topic is the only series key'])
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads epoch milliseconds from a number, Date, or parseable string.
|
|
3
|
+
*
|
|
4
|
+
* @param {Date|number|string} value - instant
|
|
5
|
+
* @returns {number} epoch milliseconds, or NaN
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* epoch('2024-11-02T03:04:05.137Z');
|
|
9
|
+
*/
|
|
10
|
+
function epoch(value) {
|
|
11
|
+
if (typeof value === 'number') {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (value instanceof Date) {
|
|
15
|
+
return value.getTime();
|
|
16
|
+
}
|
|
17
|
+
return Date.parse(String(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Converts an instant and rejects values that are not a time.
|
|
22
|
+
*
|
|
23
|
+
* @param {Date|number|string} value - instant
|
|
24
|
+
* @returns {number} epoch milliseconds
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* stamp('2024-11-02T03:04:05.137Z');
|
|
28
|
+
*/
|
|
29
|
+
function stamp(value) {
|
|
30
|
+
const at = epoch(value);
|
|
31
|
+
if (Number.isNaN(at)) {
|
|
32
|
+
throw new Error(`Coverage overlay instant is not a time: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
return at;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads a segment bound from one of the accepted field names.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} row - segment
|
|
41
|
+
* @param {Array<string>} names - field names
|
|
42
|
+
* @returns {Date|number|string} instant
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* field({ start_time: '2024-11-02T03:04:05.137Z' }, ['start_time', 'from']);
|
|
46
|
+
*/
|
|
47
|
+
function field(row, names) {
|
|
48
|
+
const name = names.find((key) => {
|
|
49
|
+
return Object.hasOwn(row, key);
|
|
50
|
+
});
|
|
51
|
+
if (!name) {
|
|
52
|
+
throw new Error(`Coverage overlay bound is missing: ${names.join(',')}`);
|
|
53
|
+
}
|
|
54
|
+
return row[name];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalizes a timeline row to epoch bounds and a name.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} row - segment
|
|
61
|
+
* @returns {{from: number, name: string, to: number}} interval
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* bounds({ name: 'off', start_time: 0, end_time: 1 });
|
|
65
|
+
*/
|
|
66
|
+
function bounds(row) {
|
|
67
|
+
return {
|
|
68
|
+
from: stamp(field(row, ['start_time', 'from', 'start'])),
|
|
69
|
+
name: typeof row.name === 'string' ? row.name : '',
|
|
70
|
+
to: stamp(field(row, ['end_time', 'to', 'end']))
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Unique sorted cut points of a span and the intervals that cross it.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} span - coverage span
|
|
78
|
+
* @param {Array<object>} rows - normalized intervals
|
|
79
|
+
* @returns {Array<number>} epochs
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* cuts({ from: 'a', to: 'b' }, []);
|
|
83
|
+
*/
|
|
84
|
+
function cuts(span, rows) {
|
|
85
|
+
const start = stamp(span.from);
|
|
86
|
+
const stop = stamp(span.to);
|
|
87
|
+
const marks = [start, stop];
|
|
88
|
+
rows.forEach((row) => {
|
|
89
|
+
if (row.from > start && row.from < stop) {
|
|
90
|
+
marks.push(row.from);
|
|
91
|
+
}
|
|
92
|
+
if (row.to > start && row.to < stop) {
|
|
93
|
+
marks.push(row.to);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return Array.from(new Set(marks)).sort((left, right) => {
|
|
97
|
+
return left - right;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Timeline name that overlaps an open interval, or an empty string.
|
|
103
|
+
*
|
|
104
|
+
* @param {Array<object>} rows - normalized intervals
|
|
105
|
+
* @param {number} from - start epoch
|
|
106
|
+
* @param {number} to - end epoch
|
|
107
|
+
* @returns {string} name
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* title([{ from: 0, name: 'off', to: 10 }], 0, 10);
|
|
111
|
+
*/
|
|
112
|
+
function title(rows, from, to) {
|
|
113
|
+
const hit = rows.find((row) => {
|
|
114
|
+
return row.from < to && row.to > from;
|
|
115
|
+
});
|
|
116
|
+
if (!hit) {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
return hit.name;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Splits one coverage span on overlapping timeline names.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} span - coverage span
|
|
126
|
+
* @param {Array<object>} rows - normalized intervals
|
|
127
|
+
* @returns {Array<object>} painted spans
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* paint({ from: 'a', status: 'present', to: 'b' }, []);
|
|
131
|
+
*/
|
|
132
|
+
function paint(span, rows) {
|
|
133
|
+
const marks = cuts(span, rows);
|
|
134
|
+
return marks.slice(0, -1).map((from, index) => {
|
|
135
|
+
const to = marks[index + 1];
|
|
136
|
+
return {
|
|
137
|
+
from: new Date(from).toISOString(),
|
|
138
|
+
phase: title(rows, from, to),
|
|
139
|
+
status: span.status,
|
|
140
|
+
to: new Date(to).toISOString()
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Paints overlapping timeline names onto coverage spans.
|
|
147
|
+
*
|
|
148
|
+
* coverageSpans stays a status classifier. This join splits a span wherever
|
|
149
|
+
* a timeline interval starts or ends and copies that interval name as phase.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<object>} spans - coverage spans
|
|
152
|
+
* @param {Array<object>} segments - timeline rows
|
|
153
|
+
* @returns {Array<object>} spans with phase
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* coverageOverlay([{ from, status: 'present', to }], [{ name, start_time, end_time }]);
|
|
157
|
+
*/
|
|
158
|
+
export default function coverageOverlay(spans, segments) {
|
|
159
|
+
const rows = (Array.isArray(segments) ? segments : []).map(bounds);
|
|
160
|
+
const list = Array.isArray(spans) ? spans : [];
|
|
161
|
+
return list.flatMap((span) => {
|
|
162
|
+
return paint(span, rows);
|
|
163
|
+
});
|
|
164
|
+
}
|