@yarkivaev/scada 2.3.54 → 2.3.56
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 +1 -1
- package/src/application/foldedMetricsSink.js +22 -0
- package/src/application/metricsPlant.js +1 -0
- package/src/application/plantApi.js +2 -0
- package/src/application/plantServer.js +10 -3
- package/src/application/shopWithTimeline.js +51 -1
- 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/cycleLookback.js +37 -0
- package/src/domain/timeline/timeline.js +6 -0
- package/src/infrastructure/client/machineClient.js +6 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +33 -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/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/messaging/stomp/retagBody.js +39 -0
- package/src/infrastructure/messaging/stomp/segmentRetags.js +35 -0
- package/src/infrastructure/persistence/memory/checkpoints.js +13 -0
- package/src/infrastructure/persistence/memory/segments.js +83 -33
- package/src/infrastructure/persistence/memory/timeline.js +21 -0
- package/src/infrastructure/persistence/pg/checkpoints.js +14 -0
- package/src/infrastructure/persistence/pg/intervalFoldPg.js +49 -0
- package/src/infrastructure/persistence/pg/segments.js +61 -21
- 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
|
@@ -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
|
+
}
|
|
@@ -29,6 +29,7 @@ export default async function metricsPlant(machines, options) {
|
|
|
29
29
|
pool,
|
|
30
30
|
alerts: options.alerts,
|
|
31
31
|
userDecisions: options.userDecisions,
|
|
32
|
+
segments: options.segments,
|
|
32
33
|
shopName: options.shopName
|
|
33
34
|
});
|
|
34
35
|
return plant(initialized({ [shop.name()]: shop }, Object.values));
|
|
@@ -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),
|
|
@@ -6,6 +6,7 @@ import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
|
6
6
|
import stompAlerts from '../infrastructure/messaging/stomp/alerts/stompAlerts.js';
|
|
7
7
|
import stompTimelineSegments from '../infrastructure/messaging/stomp/stompTimelineSegments.js';
|
|
8
8
|
import userDecisions from '../infrastructure/messaging/stomp/userDecisions.js';
|
|
9
|
+
import segmentRetags from '../infrastructure/messaging/stomp/segmentRetags.js';
|
|
9
10
|
import { parseRequestTimeoutMs, virtualClock } from '@yarkivaev/simple-server';
|
|
10
11
|
|
|
11
12
|
function stompCollectorFactory(stompUrl, destination, credentials) {
|
|
@@ -63,7 +64,13 @@ async function initStomp(stomp, translations, requirePool) {
|
|
|
63
64
|
passcode: stomp.passcode,
|
|
64
65
|
host: stomp.host
|
|
65
66
|
});
|
|
66
|
-
|
|
67
|
+
const segments = segmentRetags({
|
|
68
|
+
stompUrl: stomp.url,
|
|
69
|
+
login: stomp.login,
|
|
70
|
+
passcode: stomp.passcode,
|
|
71
|
+
host: stomp.host
|
|
72
|
+
});
|
|
73
|
+
return { alerts, userDecisions: decisions, segments };
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -79,8 +86,8 @@ async function initStomp(stomp, translations, requirePool) {
|
|
|
79
86
|
* await plantServer({ port: 3000, basePath: '/api/v1', plantFactory, extraRoutes });
|
|
80
87
|
*/
|
|
81
88
|
export default async function plantServer(config) {
|
|
82
|
-
const { alerts, userDecisions: decisions } = await initStomp(config.stomp, config.translations, config.requirePool);
|
|
83
|
-
const p = await config.plantFactory({ alerts, userDecisions: decisions });
|
|
89
|
+
const { alerts, userDecisions: decisions, segments: retags } = await initStomp(config.stomp, config.translations, config.requirePool);
|
|
90
|
+
const p = await config.plantFactory({ alerts, userDecisions: decisions, segments: retags });
|
|
84
91
|
const segments = wireTimelineSegments(config.stomp, p);
|
|
85
92
|
const clock = virtualClock(() => {
|
|
86
93
|
return new Date();
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import pubsub from '../domain/shared/pubsub.js';
|
|
2
2
|
import timeline from '../domain/timeline/timeline.js';
|
|
3
3
|
import pgTimeline from '../infrastructure/persistence/pg/timeline.js';
|
|
4
|
+
import segmentStatePg from '../infrastructure/persistence/pg/segments.js';
|
|
4
5
|
import memoryTimelineStore, { memoryTimelineRead } from '../infrastructure/persistence/memory/timeline.js';
|
|
5
6
|
import ownerTimeline from '../infrastructure/messaging/ownership/ownerTimeline.js';
|
|
6
7
|
import stompTimeline from '../infrastructure/messaging/stomp/timeline.js';
|
|
8
|
+
import retagBody from '../infrastructure/messaging/stomp/retagBody.js';
|
|
7
9
|
|
|
8
10
|
function parseStart(requestId) {
|
|
9
11
|
const raw = decodeURIComponent(String(requestId));
|
|
@@ -31,6 +33,7 @@ function memoryTimelinePort(store, bus) {
|
|
|
31
33
|
list: port.list,
|
|
32
34
|
rowAt: port.rowAt,
|
|
33
35
|
pending: port.pending,
|
|
36
|
+
latest: port.latest,
|
|
34
37
|
stream: port.stream,
|
|
35
38
|
bus,
|
|
36
39
|
retag(start, tags, properties, audit) {
|
|
@@ -69,6 +72,34 @@ function writePort(name, decisions, owners) {
|
|
|
69
72
|
}, owners)(name);
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
function ownsLocal(owners, name) {
|
|
76
|
+
if (!owners) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return owners.resolve(name).kind !== 'edge';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function persistTags(pool, patch) {
|
|
83
|
+
const state = segmentStatePg(pool);
|
|
84
|
+
const tagsJson = JSON.stringify(patch.tags);
|
|
85
|
+
const propsJson = JSON.stringify(patch.properties || {});
|
|
86
|
+
const count = patch.resolved
|
|
87
|
+
? await state.resolveRequest(patch.machine, patch.start, tagsJson, propsJson)
|
|
88
|
+
: await state.retag(patch.machine, patch.start, tagsJson, propsJson);
|
|
89
|
+
if (!count) {
|
|
90
|
+
throw new RangeError(`Segment ${patch.machine} at ${patch.start.toISOString()} was not updated`);
|
|
91
|
+
}
|
|
92
|
+
return state.rowAt(patch.machine, patch.start);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function confirm(pool, patch, segments) {
|
|
96
|
+
const row = await persistTags(pool, patch);
|
|
97
|
+
if (segments) {
|
|
98
|
+
await segments.publish(retagBody(patch.machine, row, patch.tags, patch.properties || {}));
|
|
99
|
+
}
|
|
100
|
+
return row;
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
/**
|
|
73
104
|
* Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
|
|
74
105
|
*
|
|
@@ -82,7 +113,7 @@ function writePort(name, decisions, owners) {
|
|
|
82
113
|
* const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
|
|
83
114
|
*/
|
|
84
115
|
export default function shopWithTimeline(name, options) {
|
|
85
|
-
const { pool, userDecisions: decisions, owners } = options;
|
|
116
|
+
const { pool, userDecisions: decisions, owners, segments } = options;
|
|
86
117
|
if (pool && decisions) {
|
|
87
118
|
const bus = pubsub();
|
|
88
119
|
const read = pgTimeline(pool, name);
|
|
@@ -92,14 +123,33 @@ export default function shopWithTimeline(name, options) {
|
|
|
92
123
|
list: port.list,
|
|
93
124
|
rowAt: port.rowAt,
|
|
94
125
|
pending: port.pending,
|
|
126
|
+
latest: port.latest,
|
|
95
127
|
stream: port.stream,
|
|
96
128
|
bus,
|
|
97
129
|
async retag(start, tags, properties, audit) {
|
|
130
|
+
if (ownsLocal(owners, name)) {
|
|
131
|
+
await confirm(pool, {
|
|
132
|
+
machine: name,
|
|
133
|
+
start,
|
|
134
|
+
tags,
|
|
135
|
+
properties,
|
|
136
|
+
resolved: false
|
|
137
|
+
}, segments);
|
|
138
|
+
}
|
|
98
139
|
await write.retag(start, tags, properties, audit);
|
|
99
140
|
bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
|
|
100
141
|
},
|
|
101
142
|
async respond(requestId, body, audit) {
|
|
102
143
|
const start = parseStart(requestId);
|
|
144
|
+
if (ownsLocal(owners, name)) {
|
|
145
|
+
await confirm(pool, {
|
|
146
|
+
machine: name,
|
|
147
|
+
start,
|
|
148
|
+
tags: body.tags,
|
|
149
|
+
properties: body.properties || {},
|
|
150
|
+
resolved: true
|
|
151
|
+
}, segments);
|
|
152
|
+
}
|
|
103
153
|
await write.respond(start, body.tags, body.properties || {}, audit);
|
|
104
154
|
bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
|
|
105
155
|
return { id: requestId, ...body };
|
|
@@ -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,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function tagList(row) {
|
|
2
|
+
const raw = row.tags;
|
|
3
|
+
if (Array.isArray(raw)) {
|
|
4
|
+
return raw;
|
|
5
|
+
}
|
|
6
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
}
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walks newest-first timeline rows until a reset tag and returns them oldest-first.
|
|
14
|
+
*
|
|
15
|
+
* Reset ids are opaque; callers pass cycle start/stop tags.
|
|
16
|
+
*
|
|
17
|
+
* @param {Array<object>} rows - newest-first rows with tags
|
|
18
|
+
* @param {Array<string>} resetTags - tag ids that close the lookback
|
|
19
|
+
* @returns {Array<object>} chronological slice including the reset row
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* cycleLookback(newestFirst, ['cycle-start', 'cycle-stop'])
|
|
23
|
+
*/
|
|
24
|
+
export default function cycleLookback(rows, resetTags) {
|
|
25
|
+
const reset = new Set(resetTags);
|
|
26
|
+
const collected = [];
|
|
27
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
28
|
+
const row = rows[i];
|
|
29
|
+
collected.push(row);
|
|
30
|
+
if (tagList(row).some((id) => {
|
|
31
|
+
return reset.has(id);
|
|
32
|
+
})) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return collected.reverse();
|
|
37
|
+
}
|
|
@@ -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) {
|
|
@@ -37,6 +37,38 @@ function segmentRoute(token, checkpointState) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function cyclePriorQuery(query) {
|
|
41
|
+
if (!query.machineId) {
|
|
42
|
+
return { error: 'machineId is required' };
|
|
43
|
+
}
|
|
44
|
+
const before = parseFrom(query.before);
|
|
45
|
+
if (before === null) {
|
|
46
|
+
return { error: 'before must be a number' };
|
|
47
|
+
}
|
|
48
|
+
const reset = toTopicList(query.reset);
|
|
49
|
+
if (reset.length === 0) {
|
|
50
|
+
return { error: 'reset is required' };
|
|
51
|
+
}
|
|
52
|
+
return { machineId: query.machineId, before, reset };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cyclePriorRoute(token, checkpointState) {
|
|
56
|
+
return route('GET', '/v1/checkpoint/cycle-prior', async (req, res, params, query) => {
|
|
57
|
+
void params;
|
|
58
|
+
if (!hasAccess(req, token)) {
|
|
59
|
+
sendForbidden(res);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const parsed = cyclePriorQuery(query);
|
|
63
|
+
if (parsed.error) {
|
|
64
|
+
errorResponse('BAD_REQUEST', parsed.error, 400).send(res);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const items = await checkpointState.cyclePrior(parsed.machineId, parsed.before, parsed.reset);
|
|
68
|
+
jsonResponse({ items }).send(res);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
export default function checkpointRoutes(token, checkpointState) {
|
|
41
73
|
return [
|
|
42
74
|
route('GET', '/v1/checkpoint/replay-cursor', async (req, res, params, query) => {
|
|
@@ -53,6 +85,7 @@ export default function checkpointRoutes(token, checkpointState) {
|
|
|
53
85
|
jsonResponse({ machineId: query.machineId, cursor }).send(res);
|
|
54
86
|
}),
|
|
55
87
|
segmentRoute(token, checkpointState),
|
|
88
|
+
cyclePriorRoute(token, checkpointState),
|
|
56
89
|
route('GET', '/v1/checkpoint/pending-segments', async (req, res) => {
|
|
57
90
|
if (!hasAccess(req, token)) {
|
|
58
91
|
sendForbidden(res);
|
|
@@ -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(),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function asList(raw) {
|
|
2
|
+
if (Array.isArray(raw)) {
|
|
3
|
+
return raw;
|
|
4
|
+
}
|
|
5
|
+
if (typeof raw === 'string' && raw.length > 0) {
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a segments-exchange retag payload matching supervisor SegmentMessage.retag.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} machine - machine id
|
|
15
|
+
* @param {object} row - persisted segment with start_time, end_time, duration, name, options
|
|
16
|
+
* @param {string[]} tags - operator tags
|
|
17
|
+
* @param {object} properties - operator properties
|
|
18
|
+
* @returns {object} STOMP JSON body
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* retagBody('icht1', row, ['to_ladle'], {})
|
|
22
|
+
*/
|
|
23
|
+
export default function retagBody(machine, row, tags, properties) {
|
|
24
|
+
const options = asList(row.options);
|
|
25
|
+
const props = properties && Object.keys(properties).length > 0 ? properties : null;
|
|
26
|
+
return {
|
|
27
|
+
type: 'retag',
|
|
28
|
+
status: 'completed',
|
|
29
|
+
machine,
|
|
30
|
+
name: row.name,
|
|
31
|
+
start: new Date(row.start_time).getTime(),
|
|
32
|
+
end: new Date(row.end_time).getTime(),
|
|
33
|
+
duration: row.duration,
|
|
34
|
+
tags,
|
|
35
|
+
options: options.length > 0 ? options : null,
|
|
36
|
+
properties: props,
|
|
37
|
+
resolved: true
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { stompSend } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DESTINATION = '/exchange/scada.segments';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Publishes operator retag envelopes to the segments exchange.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} config - publisher configuration
|
|
9
|
+
* @param {string} config.stompUrl - STOMP broker URL
|
|
10
|
+
* @param {string} [config.destination] - STOMP destination
|
|
11
|
+
* @param {string} [config.login] - STOMP login
|
|
12
|
+
* @param {string} [config.passcode] - STOMP passcode
|
|
13
|
+
* @param {string} [config.host] - STOMP vhost header
|
|
14
|
+
* @returns {object} publisher with publish(body)
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const retags = segmentRetags({ stompUrl });
|
|
18
|
+
* await retags.publish(retagBody('icht1', row, ['to_ladle'], {}));
|
|
19
|
+
*/
|
|
20
|
+
export default function segmentRetags(config) {
|
|
21
|
+
if (!config.stompUrl) {
|
|
22
|
+
throw new Error('stompUrl is required for segment retag publisher');
|
|
23
|
+
}
|
|
24
|
+
const destination = config.destination || DEFAULT_DESTINATION;
|
|
25
|
+
const stompOptions = {
|
|
26
|
+
login: config.login,
|
|
27
|
+
passcode: config.passcode,
|
|
28
|
+
host: config.host
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
async publish(body) {
|
|
32
|
+
await stompSend(config.stompUrl, destination, body, stompOptions);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import cycleLookback from '../../../domain/timeline/cycleLookback.js';
|
|
2
|
+
|
|
1
3
|
function parseJsonField(raw) {
|
|
2
4
|
if (!raw) {
|
|
3
5
|
return null;
|
|
@@ -93,6 +95,17 @@ export default function checkpointStateMemory(store) {
|
|
|
93
95
|
},
|
|
94
96
|
segment(machineId, startEpoch) {
|
|
95
97
|
return segmentAt(store, machineId, startEpoch);
|
|
98
|
+
},
|
|
99
|
+
cyclePrior(machineId, beforeEpoch, resetTags) {
|
|
100
|
+
const beforeMs = Number(beforeEpoch) * 1000;
|
|
101
|
+
const rows = store.segments.filter((row) => {
|
|
102
|
+
return row.machine === machineId && new Date(row.start_time).getTime() < beforeMs;
|
|
103
|
+
}).sort((a, b) => {
|
|
104
|
+
return new Date(b.start_time) - new Date(a.start_time);
|
|
105
|
+
});
|
|
106
|
+
return cycleLookback(rows, resetTags).map((row) => {
|
|
107
|
+
return segmentItem(row);
|
|
108
|
+
});
|
|
96
109
|
}
|
|
97
110
|
};
|
|
98
111
|
}
|
|
@@ -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,52 +47,87 @@ 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
|
-
if (row) {
|
|
69
|
-
|
|
70
|
-
row.properties = propertiesJson;
|
|
104
|
+
if (!row) {
|
|
105
|
+
return 0;
|
|
71
106
|
}
|
|
107
|
+
row.tags = tagsJson;
|
|
108
|
+
row.properties = propertiesJson;
|
|
109
|
+
return 1;
|
|
72
110
|
},
|
|
73
111
|
resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
|
|
74
112
|
const row = findRow(store, machineId, startKey);
|
|
75
|
-
if (row) {
|
|
76
|
-
|
|
77
|
-
row.properties = propertiesJson;
|
|
78
|
-
row.resolved = true;
|
|
79
|
-
row.consumed = false;
|
|
113
|
+
if (!row) {
|
|
114
|
+
return 0;
|
|
80
115
|
}
|
|
116
|
+
row.tags = tagsJson;
|
|
117
|
+
row.properties = propertiesJson;
|
|
118
|
+
row.resolved = true;
|
|
119
|
+
row.consumed = false;
|
|
120
|
+
return 1;
|
|
81
121
|
}
|
|
82
122
|
};
|
|
83
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
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import segmentStatePg from './segments.js';
|
|
2
|
+
import cycleLookback from '../../../domain/timeline/cycleLookback.js';
|
|
2
3
|
|
|
3
4
|
function parseJsonField(raw) {
|
|
4
5
|
if (!raw) {
|
|
@@ -110,6 +111,19 @@ export default function checkpointStatePg(pool, metricsEnabled = true) {
|
|
|
110
111
|
},
|
|
111
112
|
segment(machineId, startEpoch) {
|
|
112
113
|
return segmentAt(pool, machineId, startEpoch);
|
|
114
|
+
},
|
|
115
|
+
cyclePrior(machineId, beforeEpoch, resetTags) {
|
|
116
|
+
return pool.query(
|
|
117
|
+
`SELECT name, start_time, end_time, duration, tags, options, properties
|
|
118
|
+
FROM segments
|
|
119
|
+
WHERE machine = $1 AND start_time < $2
|
|
120
|
+
ORDER BY start_time DESC`,
|
|
121
|
+
[machineId, new Date(beforeEpoch * 1000)]
|
|
122
|
+
).then((result) => {
|
|
123
|
+
return cycleLookback(result.rows, resetTags).map((row) => {
|
|
124
|
+
return segmentItem(row, machineId);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
113
127
|
}
|
|
114
128
|
};
|
|
115
129
|
}
|
|
@@ -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,22 +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
|
-
await pool.query(
|
|
37
|
-
|
|
68
|
+
const result = await pool.query(
|
|
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
|
);
|
|
73
|
+
return result.rowCount;
|
|
40
74
|
},
|
|
41
75
|
async resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
|
|
42
|
-
await pool.query(
|
|
43
|
-
|
|
76
|
+
const result = await pool.query(
|
|
77
|
+
`UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE
|
|
78
|
+
WHERE machine = $3 AND start_time = $4 AND kind = 'phase'`,
|
|
44
79
|
[tagsJson, propertiesJson, machineId, startKey]
|
|
45
80
|
);
|
|
81
|
+
return result.rowCount;
|
|
46
82
|
}
|
|
47
83
|
};
|
|
48
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) => {
|