@yarkivaev/scada 2.3.55 → 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.
Files changed (27) hide show
  1. package/db/migrations/V0001__baseline.sql +4 -3
  2. package/db/migrations/V0007__segments_kind.sql +6 -0
  3. package/index.js +1 -0
  4. package/package.json +1 -1
  5. package/src/application/foldedMetricsSink.js +22 -0
  6. package/src/application/plantApi.js +2 -0
  7. package/src/application/shopWithTimeline.js +2 -0
  8. package/src/application/siteServer.js +8 -47
  9. package/src/application/siteTelemetry.js +72 -0
  10. package/src/domain/segment/dispatch.js +1 -1
  11. package/src/domain/segment/intervalFold.js +28 -0
  12. package/src/domain/segment/normalize.js +1 -0
  13. package/src/domain/timeline/timeline.js +6 -0
  14. package/src/infrastructure/client/machineClient.js +6 -0
  15. package/src/infrastructure/http/plant/json/segmentJson.js +3 -0
  16. package/src/infrastructure/http/plant/routes/stateRoute.js +87 -0
  17. package/src/infrastructure/http/plant/routes/timelineRoute.js +5 -0
  18. package/src/infrastructure/http/plant/streams/measurementStream.js +1 -3
  19. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +2 -2
  20. package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +7 -6
  21. package/src/infrastructure/ingest/telemetry/foldingSink.js +26 -0
  22. package/src/infrastructure/ingest/telemetry/opcuaLocate.js +24 -0
  23. package/src/infrastructure/persistence/memory/segments.js +71 -25
  24. package/src/infrastructure/persistence/memory/timeline.js +21 -0
  25. package/src/infrastructure/persistence/pg/intervalFoldPg.js +49 -0
  26. package/src/infrastructure/persistence/pg/segments.js +57 -19
  27. 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 idx_segments_machine_start
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,
@@ -0,0 +1,6 @@
1
+ ALTER TABLE segments ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'phase';
2
+
3
+ DROP INDEX IF EXISTS idx_segments_machine_start;
4
+
5
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_segments_machine_kind_start
6
+ ON segments (machine, kind, start_time);
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.55",
3
+ "version": "2.3.56",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 bindSilentStreams from './bindSilentStreams.js';
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(env, config.streams);
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,
@@ -20,6 +20,12 @@ export default function timeline(read, bus) {
20
20
  pending() {
21
21
  return read.pending();
22
22
  },
23
+ latest(kinds) {
24
+ if (typeof read.latest !== 'function') {
25
+ return [];
26
+ }
27
+ return read.latest(kinds);
28
+ },
23
29
  stream: bus.stream
24
30
  };
25
31
  }
@@ -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 && clock().getTime() - reading.timestamp.getTime() < RETAIN_MS) {
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
+ }
@@ -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
- * In-memory segment state port for tests and local runs.
37
- *
38
- * @param {object} store - shared mutable store with segments array
39
- * @returns {object} segments port matching segmentStatePg shape
40
- */
41
- export default function segmentStateMemory(store) {
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
- const row = findRow(store, machineId, start);
48
- return row ?? null;
92
+ return findRow(store, machineId, start) ?? null;
49
93
  },
50
94
  pendingRequestsForMachine(machineId) {
51
- return store.segments.filter((row) => {
52
- return row.machine === machineId && row.resolved === false;
53
- }).sort((a, b) => {
54
- return new Date(a.start_time) - new Date(b.start_time);
55
- }).map((row) => {
56
- return {
57
- id: row.start_time,
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
- export default function segmentStatePg(pool) {
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
- let sql = `SELECT s.name, s.start_time, s.end_time, s.duration, s.options, s.tags, s.properties
5
- FROM segments s WHERE s.machine = $1`;
6
- const prm = [machineId];
7
- if (range.from) {
8
- prm.push(range.from);
9
- sql += ` AND (s.end_time >= $${prm.length} OR s.duration = 0)`;
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
- sql += ' ORDER BY s.start_time';
16
- const result = await pool.query(sql, prm);
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 ORDER BY start_time`,
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
- 'UPDATE segments SET tags = $1, properties = $2 WHERE machine = $3 AND start_time = $4',
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
- 'UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE WHERE machine = $3 AND start_time = $4',
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) => {