@yarkivaev/scada 2.3.55 → 2.3.57
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/db/migrations/V0001__baseline.sql +4 -3
- package/db/migrations/V0007__segments_kind.sql +6 -0
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/application/foldedMetricsSink.js +22 -0
- package/src/application/plantApi.js +2 -0
- package/src/application/shopWithTimeline.js +2 -0
- package/src/application/siteServer.js +8 -47
- package/src/application/siteTelemetry.js +72 -0
- package/src/domain/segment/dispatch.js +1 -1
- package/src/domain/segment/intervalFold.js +28 -0
- package/src/domain/segment/normalize.js +1 -0
- package/src/domain/timeline/timeline.js +6 -0
- package/src/infrastructure/client/machineClient.js +6 -0
- package/src/infrastructure/http/plant/json/segmentJson.js +3 -0
- package/src/infrastructure/http/plant/routes/stateRoute.js +87 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +5 -0
- package/src/infrastructure/http/plant/streams/measurementStream.js +1 -3
- package/src/infrastructure/ingest/modbus/silentStreams.js +23 -21
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +2 -2
- package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +7 -6
- package/src/infrastructure/ingest/telemetry/foldingSink.js +26 -0
- package/src/infrastructure/ingest/telemetry/opcuaLocate.js +24 -0
- package/src/infrastructure/persistence/memory/segments.js +71 -25
- package/src/infrastructure/persistence/memory/timeline.js +21 -0
- package/src/infrastructure/persistence/pg/intervalFoldPg.js +49 -0
- package/src/infrastructure/persistence/pg/segments.js +57 -19
- package/src/infrastructure/persistence/pg/timeline.js +5 -0
|
@@ -8,11 +8,12 @@ CREATE TABLE IF NOT EXISTS segments (
|
|
|
8
8
|
tags TEXT,
|
|
9
9
|
properties TEXT,
|
|
10
10
|
resolved BOOLEAN DEFAULT TRUE,
|
|
11
|
-
consumed BOOLEAN DEFAULT TRUE
|
|
11
|
+
consumed BOOLEAN DEFAULT TRUE,
|
|
12
|
+
kind TEXT NOT NULL DEFAULT 'phase'
|
|
12
13
|
);
|
|
13
14
|
|
|
14
|
-
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
15
|
-
ON segments (machine, start_time);
|
|
15
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_segments_machine_kind_start
|
|
16
|
+
ON segments (machine, kind, start_time);
|
|
16
17
|
|
|
17
18
|
CREATE TABLE IF NOT EXISTS alerts (
|
|
18
19
|
id SERIAL PRIMARY KEY,
|
package/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { default as plantOperations } from './src/application/plantOperations.js
|
|
|
21
21
|
export { default as metricsPlant } from './src/application/metricsPlant.js';
|
|
22
22
|
export { default as plantServer } from './src/application/plantServer.js';
|
|
23
23
|
export { default as siteServer } from './src/application/siteServer.js';
|
|
24
|
+
export { default as foldedMetricsSink } from './src/application/foldedMetricsSink.js';
|
|
24
25
|
export {
|
|
25
26
|
default as siteOperatorCatalog,
|
|
26
27
|
buildSiteOperatorCatalog
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.57",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clickhouse/client": "^1.0.0",
|
|
31
31
|
"@yarkivaev/simple-server": "1.0.1",
|
|
32
|
-
"@yarkivaev/source-to-sink": "1.3.
|
|
32
|
+
"@yarkivaev/source-to-sink": "1.3.3",
|
|
33
33
|
"amqplib": "^0.10.9",
|
|
34
34
|
"pg": "^8.0.0",
|
|
35
35
|
"stompit": "^1.0.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import intervalFold from '../domain/segment/intervalFold.js';
|
|
2
|
+
import intervalFoldPg from '../infrastructure/persistence/pg/intervalFoldPg.js';
|
|
3
|
+
import foldingSink from '../infrastructure/ingest/telemetry/foldingSink.js';
|
|
4
|
+
import opcuaLocate from '../infrastructure/ingest/telemetry/opcuaLocate.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wraps a metrics sink so OPC UA points also fold into PG intervals.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} inner - sink with write(records)
|
|
10
|
+
* @param {object} pool - pg Pool
|
|
11
|
+
* @param {object} devices - device id to machine id
|
|
12
|
+
* @returns {object} sink
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const sink = foldedMetricsSink(pgMetrics, pool, { 'tlc-cm8': 'cm8' });
|
|
16
|
+
*/
|
|
17
|
+
export default function foldedMetricsSink(inner, pool, devices) {
|
|
18
|
+
if (!devices || Object.keys(devices).length === 0) {
|
|
19
|
+
return inner;
|
|
20
|
+
}
|
|
21
|
+
return foldingSink(inner, intervalFold(intervalFoldPg(pool)), opcuaLocate(devices));
|
|
22
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import machineRoute from '../infrastructure/http/plant/routes/machineRoute.js';
|
|
2
2
|
import measurementRoute from '../infrastructure/http/plant/routes/measurementRoute.js';
|
|
3
|
+
import stateRoute from '../infrastructure/http/plant/routes/stateRoute.js';
|
|
3
4
|
import measurementStream from '../infrastructure/http/plant/streams/measurementStream.js';
|
|
4
5
|
import alertRoute from '../infrastructure/http/plant/routes/alertRoute.js';
|
|
5
6
|
import alertStream from '../infrastructure/http/plant/streams/alertStream.js';
|
|
@@ -38,6 +39,7 @@ export default function plantApi(basePath, plant, config) {
|
|
|
38
39
|
const routeList = [
|
|
39
40
|
...catalogRoute(basePath, opts.tagCatalog),
|
|
40
41
|
...machineRoute(basePath, plant),
|
|
42
|
+
...stateRoute(basePath, plant),
|
|
41
43
|
...measurementStream(basePath, plant, time),
|
|
42
44
|
...measurementRoute(basePath, plant, time),
|
|
43
45
|
...alertStream(basePath, plant, time),
|
|
@@ -33,6 +33,7 @@ function memoryTimelinePort(store, bus) {
|
|
|
33
33
|
list: port.list,
|
|
34
34
|
rowAt: port.rowAt,
|
|
35
35
|
pending: port.pending,
|
|
36
|
+
latest: port.latest,
|
|
36
37
|
stream: port.stream,
|
|
37
38
|
bus,
|
|
38
39
|
retag(start, tags, properties, audit) {
|
|
@@ -122,6 +123,7 @@ export default function shopWithTimeline(name, options) {
|
|
|
122
123
|
list: port.list,
|
|
123
124
|
rowAt: port.rowAt,
|
|
124
125
|
pending: port.pending,
|
|
126
|
+
latest: port.latest,
|
|
125
127
|
stream: port.stream,
|
|
126
128
|
bus,
|
|
127
129
|
async retag(start, tags, properties, audit) {
|
|
@@ -5,10 +5,9 @@ import plantOperations from './plantOperations.js';
|
|
|
5
5
|
import edgeApi from '../infrastructure/http/edge/edgeApi.js';
|
|
6
6
|
import runRetention from '../infrastructure/ingest/db/runRetention.js';
|
|
7
7
|
import mqttMetrics from '../infrastructure/ingest/mqtt/mqttMetrics.js';
|
|
8
|
-
import amqpMetricsIngest from '../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
9
8
|
import operationSyncIngest from '../infrastructure/sync/operationSyncIngest.js';
|
|
10
9
|
import { metricsSinkFromPool } from '../infrastructure/persistence/pg/metrics.js';
|
|
11
|
-
import
|
|
10
|
+
import startTelemetryIngest from './siteTelemetry.js';
|
|
12
11
|
import timelineOperatorFromEnv from './timelineOperatorFromEnv.js';
|
|
13
12
|
import { buildSiteOperatorCatalog } from './siteOperatorCatalog.js';
|
|
14
13
|
|
|
@@ -70,50 +69,6 @@ function startOperationSync(sink, env) {
|
|
|
70
69
|
return ingest;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
function clickhouseMetricsUrl(env) {
|
|
74
|
-
return env.CLICKHOUSE_URL
|
|
75
|
-
|| (env.CLICKHOUSE_HOST ? `http://${env.CLICKHOUSE_HOST}:8123` : undefined);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function startAmqpMetrics(env, sink, onSeen) {
|
|
79
|
-
if (!env.AMQP_URL) {
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
const batchConfig = mqttConfigFromEnv(env);
|
|
83
|
-
const ingest = amqpMetricsIngest(
|
|
84
|
-
env.AMQP_URL,
|
|
85
|
-
env.AMQP_QUEUE || 'scada.telemetry.ingest',
|
|
86
|
-
sink,
|
|
87
|
-
{
|
|
88
|
-
size: batchConfig.size,
|
|
89
|
-
interval: batchConfig.interval,
|
|
90
|
-
threshold: batchConfig.threshold,
|
|
91
|
-
timeout: batchConfig.timeout,
|
|
92
|
-
prefetch: parseInt(env.AMQP_PREFETCH || '32', 10),
|
|
93
|
-
onSeen
|
|
94
|
-
}
|
|
95
|
-
);
|
|
96
|
-
ingest.start();
|
|
97
|
-
return ingest;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function startTelemetryIngest(env, streams) {
|
|
101
|
-
if (env.SINK_DB_PROFILE === 'edge' || (!env.AMQP_URL && !streams)) {
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
const url = clickhouseMetricsUrl(env);
|
|
105
|
-
if (!url) {
|
|
106
|
-
throw new Error('CLICKHOUSE_URL or CLICKHOUSE_HOST is required for AMQP telemetry ingest');
|
|
107
|
-
}
|
|
108
|
-
const sink = clickhouseSink(url, 'scada.metrics');
|
|
109
|
-
const onSeen = bindSilentStreams(streams, sink);
|
|
110
|
-
const ingest = startAmqpMetrics(env, sink, onSeen);
|
|
111
|
-
if (streams) {
|
|
112
|
-
streams.start();
|
|
113
|
-
}
|
|
114
|
-
return { ingest, streams };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
72
|
function plantFactoryWithOperations(plantFactory, ops, sink) {
|
|
118
73
|
return (ctx) => {
|
|
119
74
|
const built = plantFactory({ ...ctx, operations: ops }, sink);
|
|
@@ -168,7 +123,13 @@ export default async function siteServer(config) {
|
|
|
168
123
|
});
|
|
169
124
|
await sink.run(http);
|
|
170
125
|
const mqtt = startMqtt(sink, env);
|
|
171
|
-
const telemetry = startTelemetryIngest(
|
|
126
|
+
const telemetry = startTelemetryIngest(
|
|
127
|
+
env,
|
|
128
|
+
config.streams,
|
|
129
|
+
mqttConfigFromEnv(env),
|
|
130
|
+
sink.pool,
|
|
131
|
+
config.intervalDevices
|
|
132
|
+
);
|
|
172
133
|
const operationSync = startOperationSync(sink, env);
|
|
173
134
|
const basePath = config.basePath || '/api/v1';
|
|
174
135
|
const catalog = buildSiteOperatorCatalog(config, basePath, sink, env);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { clickhouseSink } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import amqpMetricsIngest from '../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
3
|
+
import foldedMetricsSink from './foldedMetricsSink.js';
|
|
4
|
+
import bindSilentStreams from './bindSilentStreams.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the ClickHouse HTTP URL from site env.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} env - process env
|
|
10
|
+
* @returns {string|undefined} URL
|
|
11
|
+
*/
|
|
12
|
+
function clickhouseMetricsUrl(env) {
|
|
13
|
+
return env.CLICKHOUSE_URL
|
|
14
|
+
|| (env.CLICKHOUSE_HOST ? `http://${env.CLICKHOUSE_HOST}:8123` : undefined);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Starts AMQP telemetry ingest into a metrics sink.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} env - process env
|
|
21
|
+
* @param {object} sink - metrics sink
|
|
22
|
+
* @param {function} onSeen - silent-stream callback
|
|
23
|
+
* @param {object} batchConfig - size interval threshold timeout
|
|
24
|
+
* @returns {object|undefined} ingest handle
|
|
25
|
+
*/
|
|
26
|
+
function startAmqpMetrics(env, sink, onSeen, batchConfig) {
|
|
27
|
+
if (!env.AMQP_URL) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const ingest = amqpMetricsIngest(
|
|
31
|
+
env.AMQP_URL,
|
|
32
|
+
env.AMQP_QUEUE || 'scada.telemetry.ingest',
|
|
33
|
+
sink,
|
|
34
|
+
{
|
|
35
|
+
size: batchConfig.size,
|
|
36
|
+
interval: batchConfig.interval,
|
|
37
|
+
threshold: batchConfig.threshold,
|
|
38
|
+
timeout: batchConfig.timeout,
|
|
39
|
+
prefetch: parseInt(env.AMQP_PREFETCH || '32', 10),
|
|
40
|
+
onSeen
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
ingest.start();
|
|
44
|
+
return ingest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Starts central AMQP telemetry ingest and optional silent streams.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} env - process env
|
|
51
|
+
* @param {object} [streams] - silent Modbus streams
|
|
52
|
+
* @param {object} batchConfig - MQTT/AMQP batch options
|
|
53
|
+
* @param {object} pool - pg Pool
|
|
54
|
+
* @param {object} [devices] - OPC UA device to machine map
|
|
55
|
+
* @returns {object|undefined} ingest handles
|
|
56
|
+
*/
|
|
57
|
+
export default function startTelemetryIngest(env, streams, batchConfig, pool, devices) {
|
|
58
|
+
if (env.SINK_DB_PROFILE === 'edge' || (!env.AMQP_URL && !streams)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const url = clickhouseMetricsUrl(env);
|
|
62
|
+
if (!url) {
|
|
63
|
+
throw new Error('CLICKHOUSE_URL or CLICKHOUSE_HOST is required for AMQP telemetry ingest');
|
|
64
|
+
}
|
|
65
|
+
const sink = foldedMetricsSink(clickhouseSink(url, 'scada.metrics'), pool, devices);
|
|
66
|
+
const onSeen = bindSilentStreams(streams, sink);
|
|
67
|
+
const ingest = startAmqpMetrics(env, sink, onSeen, batchConfig);
|
|
68
|
+
if (streams) {
|
|
69
|
+
streams.start();
|
|
70
|
+
}
|
|
71
|
+
return { ingest, streams };
|
|
72
|
+
}
|
|
@@ -20,7 +20,7 @@ export default function segmentDispatch(segmentSink, retag, splitSink, closer) {
|
|
|
20
20
|
await splitSink.write([record]);
|
|
21
21
|
} else if (record.type === 'segment') {
|
|
22
22
|
if (record.duration === 0) {
|
|
23
|
-
await closer.close(record.machine, record.start_time);
|
|
23
|
+
await closer.close(record.machine, record.start_time, record.kind || 'phase');
|
|
24
24
|
}
|
|
25
25
|
await segmentSink.write([record]);
|
|
26
26
|
} else {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds successive equal samples into open state intervals.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} port - open, begin, extend, finish
|
|
5
|
+
* @returns {object} collector with accept(sample)
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const fold = intervalFold(port);
|
|
9
|
+
* await fold.accept({ machine: 'cm8', kind: 'ladle_moving', value: 1, ts: Date.now() });
|
|
10
|
+
*/
|
|
11
|
+
export default function intervalFold(port) {
|
|
12
|
+
return {
|
|
13
|
+
async accept(sample) {
|
|
14
|
+
const name = String(sample.value);
|
|
15
|
+
const open = await port.open(sample.machine, sample.kind);
|
|
16
|
+
if (!open) {
|
|
17
|
+
await port.begin({ machine: sample.machine, kind: sample.kind, name, ts: sample.ts });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (open.name === name) {
|
|
21
|
+
await port.extend(open, sample.ts);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
await port.finish(open, sample.ts);
|
|
25
|
+
await port.begin({ machine: sample.machine, kind: sample.kind, name, ts: sample.ts });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -20,6 +20,7 @@ export default function segmentNormalize(parsed) {
|
|
|
20
20
|
type: parsed.type,
|
|
21
21
|
machine: parsed.machine,
|
|
22
22
|
name: parsed.name,
|
|
23
|
+
kind: typeof parsed.kind === 'string' && parsed.kind.length > 0 ? parsed.kind : 'phase',
|
|
23
24
|
start_time: new Date(parsed.start).toISOString(),
|
|
24
25
|
end_time: new Date(parsed.end).toISOString(),
|
|
25
26
|
duration: parsed.duration,
|
|
@@ -30,6 +30,9 @@ function rangeQuery(options) {
|
|
|
30
30
|
if (options && options.to) {
|
|
31
31
|
params.set('to', options.to);
|
|
32
32
|
}
|
|
33
|
+
if (options && Array.isArray(options.kinds) && options.kinds.length > 0) {
|
|
34
|
+
params.set('kinds', options.kinds.join(','));
|
|
35
|
+
}
|
|
33
36
|
const qs = params.toString();
|
|
34
37
|
return qs ? `?${qs}` : '';
|
|
35
38
|
}
|
|
@@ -78,6 +81,9 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource,
|
|
|
78
81
|
info() {
|
|
79
82
|
return request('');
|
|
80
83
|
},
|
|
84
|
+
state() {
|
|
85
|
+
return request('/state');
|
|
86
|
+
},
|
|
81
87
|
measurements(options) {
|
|
82
88
|
const params = new URLSearchParams();
|
|
83
89
|
if (options && options.keys) {
|
|
@@ -14,6 +14,9 @@ export default function segmentJson(row) {
|
|
|
14
14
|
end: row.duration === 0 ? new Date().toISOString() : row.end_time.toISOString(),
|
|
15
15
|
duration: row.duration
|
|
16
16
|
};
|
|
17
|
+
if (row.kind) {
|
|
18
|
+
mapped.kind = row.kind;
|
|
19
|
+
}
|
|
17
20
|
if (row.options) {
|
|
18
21
|
mapped.options = row.options;
|
|
19
22
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
|
+
import { jsonResponse, route } from '@yarkivaev/simple-server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps a sensor current() row to a state item.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} sensor - sensor with current()
|
|
8
|
+
* @param {string} key - sensor key
|
|
9
|
+
* @returns {Promise<object>} state item
|
|
10
|
+
*/
|
|
11
|
+
async function readingOf(sensor, key) {
|
|
12
|
+
const row = await sensor.current();
|
|
13
|
+
if (!row.found) {
|
|
14
|
+
return { key, found: false };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
key,
|
|
18
|
+
found: true,
|
|
19
|
+
value: row.value,
|
|
20
|
+
timestamp: row.timestamp.toISOString(),
|
|
21
|
+
unit: row.unit
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Overlays latest interval rows onto sensor snapshot items.
|
|
27
|
+
*
|
|
28
|
+
* @param {Array<object>} items - state items
|
|
29
|
+
* @param {Array<object>} rows - latest timeline rows
|
|
30
|
+
* @returns {Array<object>} items
|
|
31
|
+
*/
|
|
32
|
+
function overlay(items, rows) {
|
|
33
|
+
const byKey = new Map(items.map((item) => {
|
|
34
|
+
return [item.key, item];
|
|
35
|
+
}));
|
|
36
|
+
rows.forEach((row) => {
|
|
37
|
+
const item = byKey.get(row.kind);
|
|
38
|
+
if (!item) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
item.found = true;
|
|
42
|
+
item.value = Number(row.name);
|
|
43
|
+
item.timestamp = row.end_time.toISOString();
|
|
44
|
+
});
|
|
45
|
+
return items;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Builds the latest machine snapshot from sensors and intervals.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} machine - plant machine
|
|
52
|
+
* @returns {Promise<Array<object>>} state items
|
|
53
|
+
*/
|
|
54
|
+
async function snapshot(machine) {
|
|
55
|
+
const keys = Object.keys(machine.sensors || {});
|
|
56
|
+
const items = await Promise.all(keys.map((key) => {
|
|
57
|
+
return readingOf(machine.sensors[key], key);
|
|
58
|
+
}));
|
|
59
|
+
if (!machine.timeline || typeof machine.timeline.latest !== 'function') {
|
|
60
|
+
return items;
|
|
61
|
+
}
|
|
62
|
+
const rows = await machine.timeline.latest(keys);
|
|
63
|
+
return overlay(items, rows);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Latest machine state route.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} basePath - API prefix
|
|
70
|
+
* @param {object} plant - plant domain
|
|
71
|
+
* @returns {array} routes
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* stateRoute('/api/v1', plant);
|
|
75
|
+
*/
|
|
76
|
+
export default function stateRoute(basePath, plant) {
|
|
77
|
+
return [
|
|
78
|
+
route('GET', `${basePath}/machines/:machineId/state`, async (req, res, params) => {
|
|
79
|
+
const result = machineInPlant(plant, params.machineId);
|
|
80
|
+
if (!result) {
|
|
81
|
+
jsonResponse({ items: [] }).send(res);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
jsonResponse({ items: await snapshot(result.machine) }).send(res);
|
|
85
|
+
})
|
|
86
|
+
];
|
|
87
|
+
}
|
|
@@ -67,6 +67,11 @@ export default function timelineRoute(basePath, plant, operatorOptions, decorate
|
|
|
67
67
|
if (query.to) {
|
|
68
68
|
options.to = query.to;
|
|
69
69
|
}
|
|
70
|
+
if (query.kinds) {
|
|
71
|
+
options.kinds = query.kinds.split(',').filter((kind) => {
|
|
72
|
+
return kind.length > 0;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
70
75
|
const rows = await decorate(params.machineId, await result.machine.timeline.list(options));
|
|
71
76
|
jsonResponse({ items: rows.map(segmentJson) }).send(res);
|
|
72
77
|
}),
|
|
@@ -13,8 +13,6 @@ import { route, sseResponse, timeExpression } from '@yarkivaev/simple-server';
|
|
|
13
13
|
* @example
|
|
14
14
|
* const routes = measurementStream('/api/v1', plant, clock);
|
|
15
15
|
*/
|
|
16
|
-
const RETAIN_MS = 10000;
|
|
17
|
-
|
|
18
16
|
export default function measurementStream(basePath, plant, clock) {
|
|
19
17
|
function beginning() {
|
|
20
18
|
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
@@ -47,7 +45,7 @@ export default function measurementStream(basePath, plant, clock) {
|
|
|
47
45
|
if (machine.sensors[key]) {
|
|
48
46
|
// eslint-disable-next-line no-await-in-loop
|
|
49
47
|
const reading = await machine.sensors[key].current();
|
|
50
|
-
if (reading.found
|
|
48
|
+
if (reading.found) {
|
|
51
49
|
sse.emit('measurement', {
|
|
52
50
|
key,
|
|
53
51
|
timestamp: reading.timestamp.toISOString(),
|
|
@@ -18,37 +18,37 @@ function isSilent(lastSeen, name, clk, budgetMs) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Stops one
|
|
21
|
+
* Stops one started poll if present. The poll instance is kept for reuse.
|
|
22
22
|
*
|
|
23
|
-
* @param {
|
|
23
|
+
* @param {object} state - Gate mutable state
|
|
24
24
|
* @param {string} name - Stream id
|
|
25
25
|
*/
|
|
26
|
-
function stopOne(
|
|
27
|
-
|
|
28
|
-
if (!poll) {
|
|
26
|
+
function stopOne(state, name) {
|
|
27
|
+
if (!state.live.has(name)) {
|
|
29
28
|
return;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
state.held.get(name).stop();
|
|
31
|
+
state.live.delete(name);
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
* Starts one poll
|
|
35
|
+
* Starts one poll, opening the source only the first time.
|
|
37
36
|
*
|
|
38
|
-
* @param {
|
|
37
|
+
* @param {object} state - Gate mutable state
|
|
39
38
|
* @param {object} source - Stream source
|
|
40
|
-
* @param {object} collector - Metrics collector
|
|
41
|
-
* @param {object} clk - Clock
|
|
42
|
-
* @param {number} intervalSec - Poll interval seconds
|
|
43
39
|
*/
|
|
44
|
-
function startOne(
|
|
40
|
+
function startOne(state, source) {
|
|
45
41
|
const name = source.name();
|
|
46
|
-
if (
|
|
42
|
+
if (state.live.has(name)) {
|
|
47
43
|
return;
|
|
48
44
|
}
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
let poll = state.held.get(name);
|
|
46
|
+
if (!poll) {
|
|
47
|
+
poll = source.open(state.collector, state.clk, state.intervalSec);
|
|
48
|
+
state.held.set(name, poll);
|
|
49
|
+
}
|
|
51
50
|
poll.start();
|
|
51
|
+
state.live.add(name);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -63,9 +63,9 @@ function pulse(state) {
|
|
|
63
63
|
for (const source of state.sources) {
|
|
64
64
|
const name = source.name();
|
|
65
65
|
if (isSilent(state.lastSeen, name, state.clk, state.budgetMs)) {
|
|
66
|
-
startOne(state
|
|
66
|
+
startOne(state, source);
|
|
67
67
|
} else {
|
|
68
|
-
stopOne(state
|
|
68
|
+
stopOne(state, name);
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -88,7 +88,8 @@ function gateState(config) {
|
|
|
88
88
|
}),
|
|
89
89
|
clear: config.clear || clearInterval,
|
|
90
90
|
lastSeen: new Map(),
|
|
91
|
-
|
|
91
|
+
held: new Map(),
|
|
92
|
+
live: new Set(),
|
|
92
93
|
collector: undefined,
|
|
93
94
|
timer: undefined,
|
|
94
95
|
running: false
|
|
@@ -143,9 +144,10 @@ export default function silentStreams(config) {
|
|
|
143
144
|
state.clear(state.timer);
|
|
144
145
|
state.timer = undefined;
|
|
145
146
|
}
|
|
146
|
-
for (const name of [...state.
|
|
147
|
-
stopOne(state
|
|
147
|
+
for (const name of [...state.live]) {
|
|
148
|
+
stopOne(state, name);
|
|
148
149
|
}
|
|
150
|
+
state.held.clear();
|
|
149
151
|
},
|
|
150
152
|
pulse: runPulse
|
|
151
153
|
};
|
|
@@ -10,9 +10,9 @@ import closeOrphanOpen from '../sinks/closeOrphanOpen.js';
|
|
|
10
10
|
import closeSilentOpen from '../sinks/closeSilentOpen.js';
|
|
11
11
|
import retagSink from '../sinks/retagSink.js';
|
|
12
12
|
|
|
13
|
-
export const segmentColumns = ['machine', 'name', 'start_time', 'end_time', 'duration',
|
|
13
|
+
export const segmentColumns = ['machine', 'kind', 'name', 'start_time', 'end_time', 'duration',
|
|
14
14
|
'options', 'tags', 'properties', 'resolved'];
|
|
15
|
-
export const segmentConflict = ['machine', 'start_time'];
|
|
15
|
+
export const segmentConflict = ['machine', 'kind', 'start_time'];
|
|
16
16
|
export const segmentUpdateColumns = ['name', 'end_time', 'duration', 'options', 'resolved'];
|
|
17
17
|
export const splitUpdateColumns = ['name', 'end_time', 'duration', 'tags', 'options', 'resolved'];
|
|
18
18
|
export const segmentsIngestDestination = '/queue/scada.segments.ingest';
|
|
@@ -4,27 +4,28 @@ import processingErrorLog from '../processingErrorLog.js';
|
|
|
4
4
|
* Closes stale open segment rows for one machine before a new open segment lands.
|
|
5
5
|
*
|
|
6
6
|
* @param {object} pool - pg Pool with query() method
|
|
7
|
-
* @returns {object} closer with close(machine, startTime)
|
|
7
|
+
* @returns {object} closer with close(machine, startTime, kind)
|
|
8
8
|
*
|
|
9
9
|
* @example
|
|
10
10
|
* const closer = closeOrphanOpen(pool);
|
|
11
|
-
* await closer.close('m1', '2024-01-01T00:00:00.000Z');
|
|
11
|
+
* await closer.close('m1', '2024-01-01T00:00:00.000Z', 'phase');
|
|
12
12
|
*/
|
|
13
13
|
export default function closeOrphanOpen(pool) {
|
|
14
14
|
if (!pool || typeof pool.query !== 'function') {
|
|
15
15
|
throw new Error('Pool must have a query() method');
|
|
16
16
|
}
|
|
17
17
|
return {
|
|
18
|
-
async close(machine, startTime) {
|
|
18
|
+
async close(machine, startTime, kind) {
|
|
19
|
+
const track = kind || 'phase';
|
|
19
20
|
try {
|
|
20
21
|
await pool.query(
|
|
21
22
|
`UPDATE segments
|
|
22
23
|
SET end_time = start_time + interval '1 second', duration = 1
|
|
23
|
-
WHERE machine = $1 AND duration = 0 AND start_time <> $2`,
|
|
24
|
-
[machine, startTime]
|
|
24
|
+
WHERE machine = $1 AND kind = $3 AND duration = 0 AND start_time <> $2`,
|
|
25
|
+
[machine, startTime, track]
|
|
25
26
|
);
|
|
26
27
|
} catch (error) {
|
|
27
|
-
processingErrorLog('close_orphan_open', error, { machine, startTime });
|
|
28
|
+
processingErrorLog('close_orphan_open', error, { machine, startTime, kind: track });
|
|
28
29
|
throw error;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes metrics then folds matching records into state intervals.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} inner - sink with write(records)
|
|
5
|
+
* @param {object} fold - intervalFold collector
|
|
6
|
+
* @param {function} locate - record to sample or null
|
|
7
|
+
* @returns {object} sink with write
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const sink = foldingSink(clickhouse, fold, locate);
|
|
11
|
+
* await sink.write([{ topic, ts, value }]);
|
|
12
|
+
*/
|
|
13
|
+
export default function foldingSink(inner, fold, locate) {
|
|
14
|
+
return {
|
|
15
|
+
async write(records) {
|
|
16
|
+
await inner.write(records);
|
|
17
|
+
for (const record of records) {
|
|
18
|
+
const sample = locate(record);
|
|
19
|
+
if (sample) {
|
|
20
|
+
// eslint-disable-next-line no-await-in-loop
|
|
21
|
+
await fold.accept(sample);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps an OPC UA metrics record to an interval sample.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} devices - device id to machine id
|
|
5
|
+
* @returns {function(object): object|null} locator
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const locate = opcuaLocate({ 'tlc-cm8': 'cm8' });
|
|
9
|
+
* locate({ topic: 'OPCUA/tlc-cm8/GET/ladle_moving/VALUE', value: 1, ts: 1 });
|
|
10
|
+
*/
|
|
11
|
+
export default function opcuaLocate(devices) {
|
|
12
|
+
const map = devices || {};
|
|
13
|
+
return (record) => {
|
|
14
|
+
const parts = String(record.topic || '').split('/');
|
|
15
|
+
if (parts[0] !== 'OPCUA' || parts[2] !== 'GET' || parts[4] !== 'VALUE') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const machine = map[parts[1]];
|
|
19
|
+
if (!machine) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return { machine, kind: parts[3], value: record.value, ts: record.ts };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -2,9 +2,20 @@ function sameInstant(a, b) {
|
|
|
2
2
|
return new Date(a).getTime() === new Date(b).getTime();
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
function trackOf(row) {
|
|
6
|
+
return row.kind || 'phase';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function wantedKinds(range) {
|
|
10
|
+
if (range && Array.isArray(range.kinds) && range.kinds.length > 0) {
|
|
11
|
+
return range.kinds;
|
|
12
|
+
}
|
|
13
|
+
return ['phase'];
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
function findRow(store, machineId, start) {
|
|
6
17
|
return store.segments.find((row) => {
|
|
7
|
-
return row.machine === machineId && sameInstant(row.start_time, start);
|
|
18
|
+
return row.machine === machineId && trackOf(row) === 'phase' && sameInstant(row.start_time, start);
|
|
8
19
|
});
|
|
9
20
|
}
|
|
10
21
|
|
|
@@ -16,10 +27,14 @@ function parseListFilters(range) {
|
|
|
16
27
|
|
|
17
28
|
function filterList(store, machineId, range) {
|
|
18
29
|
const { from, to } = parseListFilters(range);
|
|
30
|
+
const kinds = wantedKinds(range);
|
|
19
31
|
return store.segments.filter((row) => {
|
|
20
32
|
if (row.machine !== machineId) {
|
|
21
33
|
return false;
|
|
22
34
|
}
|
|
35
|
+
if (!kinds.includes(trackOf(row))) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
23
38
|
if (from && new Date(row.end_time).getTime() < new Date(from).getTime() && row.duration !== 0) {
|
|
24
39
|
return false;
|
|
25
40
|
}
|
|
@@ -32,37 +47,58 @@ function filterList(store, machineId, range) {
|
|
|
32
47
|
});
|
|
33
48
|
}
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
function latestRows(store, machineId, kinds) {
|
|
51
|
+
const latest = new Map();
|
|
52
|
+
store.segments.filter((row) => {
|
|
53
|
+
return row.machine === machineId && kinds.includes(trackOf(row));
|
|
54
|
+
}).forEach((row) => {
|
|
55
|
+
const prev = latest.get(trackOf(row));
|
|
56
|
+
if (!prev || new Date(row.start_time) > new Date(prev.start_time)) {
|
|
57
|
+
latest.set(trackOf(row), row);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return Array.from(latest.values());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pendingRows(store, machineId) {
|
|
64
|
+
return store.segments.filter((row) => {
|
|
65
|
+
return row.machine === machineId && row.resolved === false && trackOf(row) === 'phase';
|
|
66
|
+
}).sort((a, b) => {
|
|
67
|
+
return new Date(a.start_time) - new Date(b.start_time);
|
|
68
|
+
}).map((row) => {
|
|
69
|
+
return {
|
|
70
|
+
id: row.start_time,
|
|
71
|
+
name: row.name,
|
|
72
|
+
start_time: row.start_time,
|
|
73
|
+
end_time: row.end_time,
|
|
74
|
+
duration: row.duration,
|
|
75
|
+
options: row.options
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function reads(store) {
|
|
42
81
|
return {
|
|
43
82
|
listForMachine(machineId, range) {
|
|
44
83
|
return filterList(store, machineId, range);
|
|
45
84
|
},
|
|
85
|
+
latestForKinds(machineId, kinds) {
|
|
86
|
+
if (!Array.isArray(kinds) || kinds.length === 0) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return latestRows(store, machineId, kinds);
|
|
90
|
+
},
|
|
46
91
|
rowAt(machineId, start) {
|
|
47
|
-
|
|
48
|
-
return row ?? null;
|
|
92
|
+
return findRow(store, machineId, start) ?? null;
|
|
49
93
|
},
|
|
50
94
|
pendingRequestsForMachine(machineId) {
|
|
51
|
-
return store
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
name: row.name,
|
|
59
|
-
start_time: row.start_time,
|
|
60
|
-
end_time: row.end_time,
|
|
61
|
-
duration: row.duration,
|
|
62
|
-
options: row.options
|
|
63
|
-
};
|
|
64
|
-
});
|
|
65
|
-
},
|
|
95
|
+
return pendingRows(store, machineId);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writes(store) {
|
|
101
|
+
return {
|
|
66
102
|
retag(machineId, start, tagsJson, propertiesJson) {
|
|
67
103
|
const row = findRow(store, machineId, start);
|
|
68
104
|
if (!row) {
|
|
@@ -85,3 +121,13 @@ export default function segmentStateMemory(store) {
|
|
|
85
121
|
}
|
|
86
122
|
};
|
|
87
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* In-memory segment state port for tests and local runs.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} store - shared mutable store with segments array
|
|
129
|
+
* @returns {object} segments port matching segmentStatePg shape
|
|
130
|
+
*/
|
|
131
|
+
export default function segmentStateMemory(store) {
|
|
132
|
+
return { ...reads(store), ...writes(store) };
|
|
133
|
+
}
|
|
@@ -20,7 +20,11 @@ export default function memoryTimelineStore() {
|
|
|
20
20
|
if (!range) {
|
|
21
21
|
return items.slice();
|
|
22
22
|
}
|
|
23
|
+
const kinds = Array.isArray(range.kinds) && range.kinds.length > 0 ? range.kinds : ['phase'];
|
|
23
24
|
return items.filter((item) => {
|
|
25
|
+
if (!kinds.includes(item.kind || 'phase')) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
24
28
|
if (range.from && item.end_time < new Date(range.from)) {
|
|
25
29
|
return false;
|
|
26
30
|
}
|
|
@@ -39,6 +43,23 @@ export default function memoryTimelineStore() {
|
|
|
39
43
|
return pending.filter((item) => {
|
|
40
44
|
return !item.resolved;
|
|
41
45
|
});
|
|
46
|
+
},
|
|
47
|
+
latest(kinds) {
|
|
48
|
+
if (!Array.isArray(kinds) || kinds.length === 0) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const found = new Map();
|
|
52
|
+
items.forEach((item) => {
|
|
53
|
+
const track = item.kind || 'phase';
|
|
54
|
+
if (!kinds.includes(track)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const prev = found.get(track);
|
|
58
|
+
if (!prev || item.start_time > prev.start_time) {
|
|
59
|
+
found.set(track, item);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return Array.from(found.values());
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres port for intervalFold.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} pool - pg Pool
|
|
5
|
+
* @returns {object} open begin extend finish
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const port = intervalFoldPg(pool);
|
|
9
|
+
* const open = await port.open('cm8', 'ladle_moving');
|
|
10
|
+
*/
|
|
11
|
+
export default function intervalFoldPg(pool) {
|
|
12
|
+
return {
|
|
13
|
+
async open(machine, kind) {
|
|
14
|
+
const result = await pool.query(
|
|
15
|
+
`SELECT machine, kind, name, start_time, end_time
|
|
16
|
+
FROM segments
|
|
17
|
+
WHERE machine = $1 AND kind = $2 AND duration = 0
|
|
18
|
+
ORDER BY start_time DESC LIMIT 1`,
|
|
19
|
+
[machine, kind]
|
|
20
|
+
);
|
|
21
|
+
return result.rows[0] || null;
|
|
22
|
+
},
|
|
23
|
+
async begin(sample) {
|
|
24
|
+
const start = new Date(sample.ts).toISOString();
|
|
25
|
+
await pool.query(
|
|
26
|
+
`INSERT INTO segments (machine, kind, name, start_time, end_time, duration, resolved, consumed)
|
|
27
|
+
VALUES ($1, $2, $3, $4, $4, 0, TRUE, TRUE)`,
|
|
28
|
+
[sample.machine, sample.kind, sample.name, start]
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
async extend(row, ts) {
|
|
32
|
+
await pool.query(
|
|
33
|
+
`UPDATE segments SET end_time = $1
|
|
34
|
+
WHERE machine = $2 AND kind = $3 AND start_time = $4 AND duration = 0`,
|
|
35
|
+
[new Date(ts).toISOString(), row.machine, row.kind, row.start_time]
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
async finish(row, ts) {
|
|
39
|
+
const end = new Date(ts);
|
|
40
|
+
const start = new Date(row.start_time);
|
|
41
|
+
const duration = Math.max(1, Math.round((end.getTime() - start.getTime()) / 1000));
|
|
42
|
+
await pool.query(
|
|
43
|
+
`UPDATE segments SET end_time = $1, duration = $2
|
|
44
|
+
WHERE machine = $3 AND kind = $4 AND start_time = $5 AND duration = 0`,
|
|
45
|
+
[end.toISOString(), duration, row.machine, row.kind, row.start_time]
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -1,25 +1,51 @@
|
|
|
1
|
-
|
|
1
|
+
function wantedKinds(range) {
|
|
2
|
+
if (range && Array.isArray(range.kinds) && range.kinds.length > 0) {
|
|
3
|
+
return range.kinds;
|
|
4
|
+
}
|
|
5
|
+
return ['phase'];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function listSql(machineId, range) {
|
|
9
|
+
let sql = `SELECT s.kind, s.name, s.start_time, s.end_time, s.duration, s.options, s.tags, s.properties
|
|
10
|
+
FROM segments s WHERE s.machine = $1`;
|
|
11
|
+
const prm = [machineId];
|
|
12
|
+
prm.push(wantedKinds(range));
|
|
13
|
+
sql += ` AND s.kind = ANY($2)`;
|
|
14
|
+
if (range.from) {
|
|
15
|
+
prm.push(range.from);
|
|
16
|
+
sql += ` AND (s.end_time >= $${prm.length} OR s.duration = 0)`;
|
|
17
|
+
}
|
|
18
|
+
if (range.to) {
|
|
19
|
+
prm.push(range.to);
|
|
20
|
+
sql += ` AND s.start_time <= $${prm.length}`;
|
|
21
|
+
}
|
|
22
|
+
sql += ' ORDER BY s.start_time';
|
|
23
|
+
return { sql, prm };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reads(pool) {
|
|
2
27
|
return {
|
|
3
28
|
async listForMachine(machineId, range) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (range.to) {
|
|
12
|
-
prm.push(range.to);
|
|
13
|
-
sql += ` AND s.start_time <= $${prm.length}`;
|
|
29
|
+
const query = listSql(machineId, range || {});
|
|
30
|
+
const result = await pool.query(query.sql, query.prm);
|
|
31
|
+
return result.rows;
|
|
32
|
+
},
|
|
33
|
+
async latestForKinds(machineId, kinds) {
|
|
34
|
+
if (!Array.isArray(kinds) || kinds.length === 0) {
|
|
35
|
+
return [];
|
|
14
36
|
}
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
const result = await pool.query(
|
|
38
|
+
`SELECT DISTINCT ON (kind) kind, name, start_time, end_time, duration, options, tags, properties
|
|
39
|
+
FROM segments WHERE machine = $1 AND kind = ANY($2)
|
|
40
|
+
ORDER BY kind, start_time DESC`,
|
|
41
|
+
[machineId, kinds]
|
|
42
|
+
);
|
|
17
43
|
return result.rows;
|
|
18
44
|
},
|
|
19
45
|
async rowAt(machineId, start) {
|
|
20
46
|
const result = await pool.query(
|
|
21
|
-
`SELECT name, start_time, end_time, duration, options, tags, properties
|
|
22
|
-
FROM segments WHERE machine = $1 AND start_time = $2`,
|
|
47
|
+
`SELECT kind, name, start_time, end_time, duration, options, tags, properties
|
|
48
|
+
FROM segments WHERE machine = $1 AND start_time = $2 AND kind = 'phase'`,
|
|
23
49
|
[machineId, start]
|
|
24
50
|
);
|
|
25
51
|
return result.rows[0] ?? null;
|
|
@@ -27,24 +53,36 @@ export default function segmentStatePg(pool) {
|
|
|
27
53
|
async pendingRequestsForMachine(machineId) {
|
|
28
54
|
const result = await pool.query(
|
|
29
55
|
`SELECT start_time AS id, name, start_time, end_time, duration, options
|
|
30
|
-
FROM segments WHERE machine = $1 AND resolved = FALSE
|
|
56
|
+
FROM segments WHERE machine = $1 AND resolved = FALSE AND kind = 'phase'
|
|
57
|
+
ORDER BY start_time`,
|
|
31
58
|
[machineId]
|
|
32
59
|
);
|
|
33
60
|
return result.rows;
|
|
34
|
-
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writes(pool) {
|
|
66
|
+
return {
|
|
35
67
|
async retag(machineId, start, tagsJson, propertiesJson) {
|
|
36
68
|
const result = await pool.query(
|
|
37
|
-
|
|
69
|
+
`UPDATE segments SET tags = $1, properties = $2
|
|
70
|
+
WHERE machine = $3 AND start_time = $4 AND kind = 'phase'`,
|
|
38
71
|
[tagsJson, propertiesJson, machineId, start]
|
|
39
72
|
);
|
|
40
73
|
return result.rowCount;
|
|
41
74
|
},
|
|
42
75
|
async resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
|
|
43
76
|
const result = await pool.query(
|
|
44
|
-
|
|
77
|
+
`UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE
|
|
78
|
+
WHERE machine = $3 AND start_time = $4 AND kind = 'phase'`,
|
|
45
79
|
[tagsJson, propertiesJson, machineId, startKey]
|
|
46
80
|
);
|
|
47
81
|
return result.rowCount;
|
|
48
82
|
}
|
|
49
83
|
};
|
|
50
84
|
}
|
|
85
|
+
|
|
86
|
+
export default function segmentStatePg(pool) {
|
|
87
|
+
return { ...reads(pool), ...writes(pool) };
|
|
88
|
+
}
|
|
@@ -4,6 +4,7 @@ function mapRow(item) {
|
|
|
4
4
|
const tags = item.tags === undefined || item.tags === null ? null : item.tags;
|
|
5
5
|
const properties = item.properties === undefined || item.properties === null ? null : item.properties;
|
|
6
6
|
return {
|
|
7
|
+
kind: item.kind || 'phase',
|
|
7
8
|
name: item.name,
|
|
8
9
|
start_time: new Date(item.start_time),
|
|
9
10
|
end_time: new Date(item.end_time),
|
|
@@ -36,6 +37,10 @@ export default function pgTimeline(pool, machineId) {
|
|
|
36
37
|
const row = await segments.rowAt(machineId, start);
|
|
37
38
|
return row ? mapRow(row) : null;
|
|
38
39
|
},
|
|
40
|
+
async latest(kinds) {
|
|
41
|
+
const rows = await segments.latestForKinds(machineId, kinds || []);
|
|
42
|
+
return rows.map(mapRow);
|
|
43
|
+
},
|
|
39
44
|
async pending() {
|
|
40
45
|
const rows = await segments.pendingRequestsForMachine(machineId);
|
|
41
46
|
return rows.map((item) => {
|