@yarkivaev/scada 2.3.86 → 2.3.87
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 +1 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/application/mcp/coverageCursor.js +116 -0
- package/src/application/metricsPlant.js +19 -1
- package/src/application/shopWithTimeline.js +15 -6
- package/src/domain/plant/plant.js +2 -1
- package/src/infrastructure/persistence/pg/alerts.js +37 -3
package/README.md
CHANGED
|
@@ -132,7 +132,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
132
132
|
| `stompTimeline`, `stompAlerts`, `userDecisions` | STOMP messaging |
|
|
133
133
|
| `machineOwners`, `ownerTimeline`, `httpTimeline`, `httpOperations` | Owner-routed timeline and operations write (local or edge HTTP) |
|
|
134
134
|
| `mqttMetrics`, `modbusMqtt`, `activityTracking`, `amqpMetricsIngest` | Ingest pipelines |
|
|
135
|
-
| `analyticsMcp`, `namespaceTree`, `coverageSpans`, `sqlGuard` | Analytics MCP, path index, coverage spans, read-only SQL guard |
|
|
135
|
+
| `analyticsMcp`, `namespaceTree`, `coverageSpans`, `coverageCursor`, `sqlGuard` | Analytics MCP, path index, coverage spans, checkpoint-or-sample cursor, read-only SQL guard |
|
|
136
136
|
|
|
137
137
|
Subpath exports:
|
|
138
138
|
|
package/index.js
CHANGED
|
@@ -88,5 +88,6 @@ export { default as siteSyncTargets } from './src/application/sync/siteSyncTarge
|
|
|
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
90
|
export { default as coverageSpans } from './src/domain/span/coverageSpans.js';
|
|
91
|
+
export { default as coverageCursor } from './src/application/mcp/coverageCursor.js';
|
|
91
92
|
export { default as sqlGuard } from './src/domain/sql/sqlGuard.js';
|
|
92
93
|
export { default as analyticsMcp } from './src/application/mcp/analyticsMcp.js';
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const EMPTY = '1970-01-01T00:00:00.000Z';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads epoch milliseconds from a cursor value.
|
|
5
|
+
*
|
|
6
|
+
* @param {Date|number|string|object} value - instant or { at }
|
|
7
|
+
* @returns {number} epoch milliseconds, or NaN
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* instant({ at: '2024-11-02T03:04:05.137Z' });
|
|
11
|
+
*/
|
|
12
|
+
function instant(value) {
|
|
13
|
+
if (value && typeof value === 'object' && !(value instanceof Date) && Object.hasOwn(value, 'at')) {
|
|
14
|
+
return instant(value.at);
|
|
15
|
+
}
|
|
16
|
+
if (value instanceof Date) {
|
|
17
|
+
return value.getTime();
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === 'number') {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
if (value === undefined || value === null || value === '') {
|
|
23
|
+
return Number.NaN;
|
|
24
|
+
}
|
|
25
|
+
return Date.parse(String(value));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Formats a covered instant, treating epoch and gaps as empty.
|
|
30
|
+
*
|
|
31
|
+
* @param {Date|number|string|object} value - instant
|
|
32
|
+
* @returns {string} ISO instant, or the empty cursor
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* covered('2024-11-02T03:04:05.137Z');
|
|
36
|
+
*/
|
|
37
|
+
function covered(value) {
|
|
38
|
+
const at = instant(value);
|
|
39
|
+
if (!Number.isFinite(at) || at <= 0) {
|
|
40
|
+
return EMPTY;
|
|
41
|
+
}
|
|
42
|
+
return new Date(at).toISOString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Latest sample instant, or the empty cursor when none exist.
|
|
47
|
+
*
|
|
48
|
+
* @param {Array} samples - instants or objects with ts
|
|
49
|
+
* @returns {string} ISO instant
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* latest([{ ts: '2024-11-02T03:04:05.137Z' }]);
|
|
53
|
+
*/
|
|
54
|
+
function latest(samples) {
|
|
55
|
+
const rows = Array.isArray(samples) ? samples : [];
|
|
56
|
+
let mark = 0;
|
|
57
|
+
for (const sample of rows) {
|
|
58
|
+
const raw = sample && typeof sample === 'object' && Object.hasOwn(sample, 'ts')
|
|
59
|
+
? sample.ts
|
|
60
|
+
: sample;
|
|
61
|
+
const at = instant(raw);
|
|
62
|
+
if (Number.isFinite(at) && at > mark) {
|
|
63
|
+
mark = at;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return mark > 0 ? new Date(mark).toISOString() : EMPTY;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reads the newest ingest checkpoint for a machine.
|
|
71
|
+
*
|
|
72
|
+
* @param {object|undefined} pool - pg pool
|
|
73
|
+
* @param {string} machine - machine id
|
|
74
|
+
* @returns {Promise<string>} ISO instant, or the empty cursor
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* checkpoint(pool, 'machine1');
|
|
78
|
+
*/
|
|
79
|
+
async function checkpoint(pool, machine) {
|
|
80
|
+
if (!pool) {
|
|
81
|
+
return EMPTY;
|
|
82
|
+
}
|
|
83
|
+
const result = await pool.query(
|
|
84
|
+
'SELECT cursor_at FROM ingest_checkpoints WHERE machine = $1 ORDER BY cursor_at DESC LIMIT 1',
|
|
85
|
+
[machine]
|
|
86
|
+
);
|
|
87
|
+
if (result.rows.length === 0) {
|
|
88
|
+
return EMPTY;
|
|
89
|
+
}
|
|
90
|
+
return covered(result.rows[0].cursor_at);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Coverage cursor: ingest checkpoint, else latest metric instant.
|
|
95
|
+
*
|
|
96
|
+
* Central ingest does not write ingest_checkpoints. When that table is empty,
|
|
97
|
+
* the latest sample is the covered bound so coverageSpans can paint present
|
|
98
|
+
* and absent instead of the whole window unknown.
|
|
99
|
+
*
|
|
100
|
+
* @param {object|undefined} pool - pg pool
|
|
101
|
+
* @param {Function} samples - async (args) => sample list
|
|
102
|
+
* @returns {Function} cursor port ({ machine, path }) => ISO instant
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* coverageCursor(pool, ({ path }) => ports.samples({ path }))({ machine: 'm1' });
|
|
106
|
+
*/
|
|
107
|
+
export default function coverageCursor(pool, samples) {
|
|
108
|
+
return async function bound(args) {
|
|
109
|
+
const machine = args.machine || args.path;
|
|
110
|
+
const mark = await checkpoint(pool, machine);
|
|
111
|
+
if (instant(mark) > 0) {
|
|
112
|
+
return mark;
|
|
113
|
+
}
|
|
114
|
+
return latest(await samples(args));
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import plant from '../domain/plant/plant.js';
|
|
2
2
|
import initialized from '../domain/shared/initialized.js';
|
|
3
|
+
import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
|
|
3
4
|
import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Plant extras: injected ports, or pgAlerts when a pool exists.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} options - optional operations and alerts
|
|
10
|
+
* @param {object|undefined} pool - pg pool
|
|
11
|
+
* @returns {object} plant options
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* extras({ operations }, pool);
|
|
15
|
+
*/
|
|
16
|
+
function extras(options, pool) {
|
|
17
|
+
return {
|
|
18
|
+
operations: options.operations,
|
|
19
|
+
alerts: options.alerts || (pool ? pgAlerts(pool) : undefined)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
/**
|
|
6
24
|
* Generic plant factory wiring sensors and a shop factory over a connection.
|
|
7
25
|
*
|
|
@@ -32,5 +50,5 @@ export default async function metricsPlant(machines, options) {
|
|
|
32
50
|
segments: options.segments,
|
|
33
51
|
shopName: options.shopName
|
|
34
52
|
});
|
|
35
|
-
return plant(initialized({ [shop.name()]: shop }, Object.values));
|
|
53
|
+
return plant(initialized({ [shop.name()]: shop }, Object.values), extras(options, pool));
|
|
36
54
|
}
|
|
@@ -73,6 +73,13 @@ function writePort(name, decisions, owners) {
|
|
|
73
73
|
}, owners)(name);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function demandWrite(name, decisions, owners) {
|
|
77
|
+
if (!decisions) {
|
|
78
|
+
throw new Error(`Timeline write requires userDecisions for ${name}`);
|
|
79
|
+
}
|
|
80
|
+
return writePort(name, decisions, owners);
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
function ownsLocal(owners, name) {
|
|
77
84
|
if (!owners) {
|
|
78
85
|
return true;
|
|
@@ -153,23 +160,23 @@ async function answer(scope, requestId, body, audit) {
|
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
/**
|
|
156
|
-
* Builds a machine timeline from PostgreSQL
|
|
163
|
+
* Builds a machine timeline from PostgreSQL when a pool exists.
|
|
157
164
|
*
|
|
158
|
-
*
|
|
165
|
+
* Reads do not need userDecisions. retag and respond throw without it.
|
|
166
|
+
* Optional owners registry routes those writes to an edge HTTP plant API.
|
|
159
167
|
*
|
|
160
168
|
* @param {string} name - machine id
|
|
161
|
-
* @param {object} options - pool, userDecisions
|
|
169
|
+
* @param {object} options - pool, optional userDecisions and owners
|
|
162
170
|
* @returns {object} timeline port with list, rowAt, pending, stream, retag, respond, bus
|
|
163
171
|
*
|
|
164
172
|
* @example
|
|
165
|
-
* const tl = shopWithTimeline('machine1', { pool
|
|
173
|
+
* const tl = shopWithTimeline('machine1', { pool });
|
|
166
174
|
*/
|
|
167
175
|
export default function shopWithTimeline(name, options) {
|
|
168
176
|
const { pool, userDecisions: decisions, owners, segments } = options;
|
|
169
|
-
if (pool
|
|
177
|
+
if (pool) {
|
|
170
178
|
const bus = pubsub();
|
|
171
179
|
const read = pgTimeline(pool, name);
|
|
172
|
-
const write = writePort(name, decisions, owners);
|
|
173
180
|
const port = timeline(read, bus);
|
|
174
181
|
return {
|
|
175
182
|
list: port.list,
|
|
@@ -179,6 +186,7 @@ export default function shopWithTimeline(name, options) {
|
|
|
179
186
|
stream: port.stream,
|
|
180
187
|
bus,
|
|
181
188
|
async retag(start, tags, properties, audit) {
|
|
189
|
+
const write = demandWrite(name, decisions, owners);
|
|
182
190
|
if (ownsLocal(owners, name)) {
|
|
183
191
|
await confirm(pool, {
|
|
184
192
|
machine: name,
|
|
@@ -192,6 +200,7 @@ export default function shopWithTimeline(name, options) {
|
|
|
192
200
|
bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
|
|
193
201
|
},
|
|
194
202
|
respond(requestId, body, audit) {
|
|
203
|
+
const write = demandWrite(name, decisions, owners);
|
|
195
204
|
return answer({ pool, owners, name, segments, write, bus }, requestId, body, audit);
|
|
196
205
|
}
|
|
197
206
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
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
|
+
* @param {object} [options] - operations, alerts, topology
|
|
7
7
|
* @returns {object} plant with shops property and init method
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
@@ -14,6 +14,7 @@ export default function plant(shops, options = {}) {
|
|
|
14
14
|
return {
|
|
15
15
|
shops,
|
|
16
16
|
operations: options.operations,
|
|
17
|
+
alerts: options.alerts,
|
|
17
18
|
topology: options.topology,
|
|
18
19
|
init() {
|
|
19
20
|
shops.init();
|
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Builds a filtered alerts SELECT.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} filters - optional machine, from, to
|
|
5
|
+
* @returns {{sql: string, prm: Array}} query
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* listSql({ machine: 'm1', from: start, to: end });
|
|
9
|
+
*/
|
|
10
|
+
function listSql(filters) {
|
|
11
|
+
const prm = [];
|
|
12
|
+
const clauses = [];
|
|
13
|
+
const scope = filters || {};
|
|
14
|
+
if (scope.machine) {
|
|
15
|
+
prm.push(scope.machine);
|
|
16
|
+
clauses.push(`machine = $${prm.length}`);
|
|
17
|
+
}
|
|
18
|
+
if (scope.from) {
|
|
19
|
+
prm.push(scope.from);
|
|
20
|
+
clauses.push(`"timestamp" >= $${prm.length}`);
|
|
21
|
+
}
|
|
22
|
+
if (scope.to) {
|
|
23
|
+
prm.push(scope.to);
|
|
24
|
+
clauses.push(`"timestamp" <= $${prm.length}`);
|
|
25
|
+
}
|
|
26
|
+
const where = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`;
|
|
27
|
+
return { prm, sql: `SELECT * FROM alerts${where} ORDER BY timestamp` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PostgreSQL alert hydration and range-read port.
|
|
3
32
|
*
|
|
4
33
|
* @param {object} pool - pg pool
|
|
5
|
-
* @returns {object} alerts port with put and listUnacknowledged
|
|
34
|
+
* @returns {object} alerts port with put, list, and listUnacknowledged
|
|
6
35
|
*
|
|
7
36
|
* @example
|
|
8
37
|
* const store = alertsStatePg(pool);
|
|
9
|
-
* const rows = await store.
|
|
38
|
+
* const rows = await store.list({ machine: 'm1', from, to });
|
|
10
39
|
*/
|
|
11
40
|
export default function alertsStatePg(pool) {
|
|
12
41
|
return {
|
|
@@ -25,6 +54,11 @@ export default function alertsStatePg(pool) {
|
|
|
25
54
|
);
|
|
26
55
|
return inserted.rows[0];
|
|
27
56
|
},
|
|
57
|
+
async list(filters) {
|
|
58
|
+
const query = listSql(filters);
|
|
59
|
+
const result = await pool.query(query.sql, query.prm);
|
|
60
|
+
return result.rows;
|
|
61
|
+
},
|
|
28
62
|
async listUnacknowledged(filters) {
|
|
29
63
|
let sql = 'SELECT * FROM alerts WHERE acknowledged = FALSE';
|
|
30
64
|
const prm = [];
|