@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.
Files changed (35) 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/metricsPlant.js +1 -0
  7. package/src/application/plantApi.js +2 -0
  8. package/src/application/plantServer.js +10 -3
  9. package/src/application/shopWithTimeline.js +51 -1
  10. package/src/application/siteServer.js +8 -47
  11. package/src/application/siteTelemetry.js +72 -0
  12. package/src/domain/segment/dispatch.js +1 -1
  13. package/src/domain/segment/intervalFold.js +28 -0
  14. package/src/domain/segment/normalize.js +1 -0
  15. package/src/domain/timeline/cycleLookback.js +37 -0
  16. package/src/domain/timeline/timeline.js +6 -0
  17. package/src/infrastructure/client/machineClient.js +6 -0
  18. package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +33 -0
  19. package/src/infrastructure/http/plant/json/segmentJson.js +3 -0
  20. package/src/infrastructure/http/plant/routes/stateRoute.js +87 -0
  21. package/src/infrastructure/http/plant/routes/timelineRoute.js +5 -0
  22. package/src/infrastructure/http/plant/streams/measurementStream.js +1 -3
  23. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +2 -2
  24. package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +7 -6
  25. package/src/infrastructure/ingest/telemetry/foldingSink.js +26 -0
  26. package/src/infrastructure/ingest/telemetry/opcuaLocate.js +24 -0
  27. package/src/infrastructure/messaging/stomp/retagBody.js +39 -0
  28. package/src/infrastructure/messaging/stomp/segmentRetags.js +35 -0
  29. package/src/infrastructure/persistence/memory/checkpoints.js +13 -0
  30. package/src/infrastructure/persistence/memory/segments.js +83 -33
  31. package/src/infrastructure/persistence/memory/timeline.js +21 -0
  32. package/src/infrastructure/persistence/pg/checkpoints.js +14 -0
  33. package/src/infrastructure/persistence/pg/intervalFoldPg.js +49 -0
  34. package/src/infrastructure/persistence/pg/segments.js +61 -21
  35. 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.54",
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
+ }
@@ -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
- return { alerts, userDecisions: decisions };
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 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,
@@ -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
+ }
@@ -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) {
@@ -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 && 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
+ }
@@ -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
- * 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
- if (row) {
69
- row.tags = tagsJson;
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
- row.tags = tagsJson;
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
- 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,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 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
- await pool.query(
37
- 'UPDATE segments SET tags = $1, properties = $2 WHERE machine = $3 AND start_time = $4',
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
- 'UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE WHERE machine = $3 AND start_time = $4',
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) => {