@yarkivaev/scada 2.3.53 → 2.3.55
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/package.json +1 -1
- package/src/application/metricsPlant.js +1 -0
- package/src/application/plantApi.js +8 -3
- package/src/application/plantServer.js +13 -5
- package/src/application/shopWithTimeline.js +49 -1
- package/src/application/siteServer.js +3 -2
- package/src/domain/timeline/cycleLookback.js +37 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +33 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +11 -6
- package/src/infrastructure/http/plant/streams/timelineStream.js +38 -23
- 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 +12 -8
- package/src/infrastructure/persistence/pg/checkpoints.js +14 -0
- package/src/infrastructure/persistence/pg/segments.js +4 -2
package/package.json
CHANGED
|
@@ -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));
|
|
@@ -12,12 +12,16 @@ import simulationRoute from '../infrastructure/http/plant/routes/simulationRoute
|
|
|
12
12
|
import catalogRoute from '../infrastructure/http/plant/routes/catalogRoute.js';
|
|
13
13
|
import { routes } from '@yarkivaev/simple-server';
|
|
14
14
|
|
|
15
|
+
function pass(_id, rows) {
|
|
16
|
+
return rows;
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* Composable plant HTTP API factory.
|
|
17
21
|
*
|
|
18
22
|
* @param {string} basePath - base URL path
|
|
19
23
|
* @param {object} plant - plant domain object with operations (optional kindSources at construction)
|
|
20
|
-
* @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat
|
|
24
|
+
* @param {object} [config] - clock, extraRoutes, requestTimeoutMs, heartbeat, decorateTimeline
|
|
21
25
|
* @returns {object} routes with list() and handle()
|
|
22
26
|
*
|
|
23
27
|
* @example
|
|
@@ -30,6 +34,7 @@ export default function plantApi(basePath, plant, config) {
|
|
|
30
34
|
return new Date();
|
|
31
35
|
});
|
|
32
36
|
const extra = opts.extraRoutes || [];
|
|
37
|
+
const decorate = opts.decorateTimeline || pass;
|
|
33
38
|
const routeList = [
|
|
34
39
|
...catalogRoute(basePath, opts.tagCatalog),
|
|
35
40
|
...machineRoute(basePath, plant),
|
|
@@ -37,8 +42,8 @@ export default function plantApi(basePath, plant, config) {
|
|
|
37
42
|
...measurementRoute(basePath, plant, time),
|
|
38
43
|
...alertStream(basePath, plant, time),
|
|
39
44
|
...alertRoute(basePath, plant),
|
|
40
|
-
...timelineRoute(basePath, plant, opts.timelineOperator),
|
|
41
|
-
...timelineStream(basePath, plant, time),
|
|
45
|
+
...timelineRoute(basePath, plant, opts.timelineOperator, decorate),
|
|
46
|
+
...timelineStream(basePath, plant, time, decorate),
|
|
42
47
|
...operationRoute(basePath, plant, opts.timelineOperator, opts.operationDecisions, opts.owners),
|
|
43
48
|
...operationStream(basePath, plant, time),
|
|
44
49
|
...heartbeatStream(basePath, time, opts.heartbeat),
|
|
@@ -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
|
/**
|
|
@@ -72,15 +79,15 @@ async function initStomp(stomp, translations, requirePool) {
|
|
|
72
79
|
* Multi-kind operations come from plant.operations built with kindSources
|
|
73
80
|
* (e.g. plantOperations(persistence, { temp })).
|
|
74
81
|
*
|
|
75
|
-
* @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool
|
|
82
|
+
* @param {object} config - port, basePath, plantFactory, extraRoutes, translations, stomp, requirePool, decorateTimeline
|
|
76
83
|
* @returns {Promise<object>} server, plant, api, segments
|
|
77
84
|
*
|
|
78
85
|
* @example
|
|
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();
|
|
@@ -95,7 +102,8 @@ export default async function plantServer(config) {
|
|
|
95
102
|
extraRoutes: extra,
|
|
96
103
|
timelineOperator: config.timelineOperator,
|
|
97
104
|
operationDecisions: config.operationDecisions,
|
|
98
|
-
owners: config.owners
|
|
105
|
+
owners: config.owners,
|
|
106
|
+
decorateTimeline: config.decorateTimeline
|
|
99
107
|
});
|
|
100
108
|
const server = http.createServer((req, res) => {
|
|
101
109
|
return api.handle(req, res);
|
|
@@ -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));
|
|
@@ -69,6 +71,34 @@ function writePort(name, decisions, owners) {
|
|
|
69
71
|
}, owners)(name);
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
function ownsLocal(owners, name) {
|
|
75
|
+
if (!owners) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return owners.resolve(name).kind !== 'edge';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function persistTags(pool, patch) {
|
|
82
|
+
const state = segmentStatePg(pool);
|
|
83
|
+
const tagsJson = JSON.stringify(patch.tags);
|
|
84
|
+
const propsJson = JSON.stringify(patch.properties || {});
|
|
85
|
+
const count = patch.resolved
|
|
86
|
+
? await state.resolveRequest(patch.machine, patch.start, tagsJson, propsJson)
|
|
87
|
+
: await state.retag(patch.machine, patch.start, tagsJson, propsJson);
|
|
88
|
+
if (!count) {
|
|
89
|
+
throw new RangeError(`Segment ${patch.machine} at ${patch.start.toISOString()} was not updated`);
|
|
90
|
+
}
|
|
91
|
+
return state.rowAt(patch.machine, patch.start);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function confirm(pool, patch, segments) {
|
|
95
|
+
const row = await persistTags(pool, patch);
|
|
96
|
+
if (segments) {
|
|
97
|
+
await segments.publish(retagBody(patch.machine, row, patch.tags, patch.properties || {}));
|
|
98
|
+
}
|
|
99
|
+
return row;
|
|
100
|
+
}
|
|
101
|
+
|
|
72
102
|
/**
|
|
73
103
|
* Builds a machine timeline from PostgreSQL persistence and STOMP user decisions.
|
|
74
104
|
*
|
|
@@ -82,7 +112,7 @@ function writePort(name, decisions, owners) {
|
|
|
82
112
|
* const tl = shopWithTimeline('machine1', { pool, userDecisions, owners });
|
|
83
113
|
*/
|
|
84
114
|
export default function shopWithTimeline(name, options) {
|
|
85
|
-
const { pool, userDecisions: decisions, owners } = options;
|
|
115
|
+
const { pool, userDecisions: decisions, owners, segments } = options;
|
|
86
116
|
if (pool && decisions) {
|
|
87
117
|
const bus = pubsub();
|
|
88
118
|
const read = pgTimeline(pool, name);
|
|
@@ -95,11 +125,29 @@ export default function shopWithTimeline(name, options) {
|
|
|
95
125
|
stream: port.stream,
|
|
96
126
|
bus,
|
|
97
127
|
async retag(start, tags, properties, audit) {
|
|
128
|
+
if (ownsLocal(owners, name)) {
|
|
129
|
+
await confirm(pool, {
|
|
130
|
+
machine: name,
|
|
131
|
+
start,
|
|
132
|
+
tags,
|
|
133
|
+
properties,
|
|
134
|
+
resolved: false
|
|
135
|
+
}, segments);
|
|
136
|
+
}
|
|
98
137
|
await write.retag(start, tags, properties, audit);
|
|
99
138
|
bus.emit({ type: 'resolved', segment: resolvedStub(start, tags, properties), audit });
|
|
100
139
|
},
|
|
101
140
|
async respond(requestId, body, audit) {
|
|
102
141
|
const start = parseStart(requestId);
|
|
142
|
+
if (ownsLocal(owners, name)) {
|
|
143
|
+
await confirm(pool, {
|
|
144
|
+
machine: name,
|
|
145
|
+
start,
|
|
146
|
+
tags: body.tags,
|
|
147
|
+
properties: body.properties || {},
|
|
148
|
+
resolved: true
|
|
149
|
+
}, segments);
|
|
150
|
+
}
|
|
103
151
|
await write.respond(start, body.tags, body.properties || {}, audit);
|
|
104
152
|
bus.emit({ type: 'resolved', request: { id: requestId, start }, audit });
|
|
105
153
|
return { id: requestId, ...body };
|
|
@@ -142,7 +142,7 @@ function siteExtraRoutes(catalog, extraRoutes) {
|
|
|
142
142
|
/**
|
|
143
143
|
* Unified site process: supervisor-sink HTTP, plant API, and optional MQTT ingest.
|
|
144
144
|
*
|
|
145
|
-
* @param {object} config - port, basePath, translations, requirePool, plantFactory, extraRoutes, operatorCatalog, kindSources, streams, env
|
|
145
|
+
* @param {object} config - port, basePath, translations, requirePool, plantFactory, extraRoutes, operatorCatalog, kindSources, streams, env, decorateTimeline
|
|
146
146
|
* @returns {Promise<object>} sink, plant, mqtt pipeline
|
|
147
147
|
*
|
|
148
148
|
* @example
|
|
@@ -185,7 +185,8 @@ export default async function siteServer(config) {
|
|
|
185
185
|
extraRoutes: siteExtraRoutes(catalog, config.extraRoutes),
|
|
186
186
|
timelineOperator: timelineOperatorFromEnv(catalog, env, config),
|
|
187
187
|
operationDecisions: catalog.decisions,
|
|
188
|
-
owners: config.owners
|
|
188
|
+
owners: config.owners,
|
|
189
|
+
decorateTimeline: config.decorateTimeline
|
|
189
190
|
});
|
|
190
191
|
return { sink, plant, mqtt, telemetry, operationSync, operatorsSync: catalog.sync };
|
|
191
192
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -9,12 +9,16 @@ async function handleOperatorWrite(gate, parsed, write) {
|
|
|
9
9
|
await write(audit);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
async function rejectUnknownTags(timeline, parsed, machineId, res) {
|
|
12
|
+
async function rejectUnknownTags(timeline, parsed, machineId, res, decorate) {
|
|
13
13
|
if (typeof timeline.rowAt !== 'function') {
|
|
14
14
|
return false;
|
|
15
15
|
}
|
|
16
16
|
const row = await timeline.rowAt(new Date(parsed.start));
|
|
17
|
-
if (!row
|
|
17
|
+
if (!row) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const [gated] = await decorate(machineId, [row]);
|
|
21
|
+
if (allowedSegmentTags(gated.options, gated.tags, parsed.tags)) {
|
|
18
22
|
return false;
|
|
19
23
|
}
|
|
20
24
|
errorResponse('BAD_REQUEST', `Tag is not in segment options for ${machineId}`, 400).send(res);
|
|
@@ -41,12 +45,13 @@ async function respondToRequest(gate, timeline, requestId, req, res) {
|
|
|
41
45
|
* @param {string} basePath - base URL path
|
|
42
46
|
* @param {object} plant - plant domain object
|
|
43
47
|
* @param {object} [operatorOptions] - provider, requireOperator, defaultUser
|
|
48
|
+
* @param {function} decorate - (machineId, rows) => rows, applied before JSON and PATCH gate
|
|
44
49
|
* @returns {array} route objects
|
|
45
50
|
*
|
|
46
51
|
* @example
|
|
47
52
|
* timelineRoute('/api/v1', plant, { provider, requireOperator: true, defaultUser: 'hmi-kiosk' });
|
|
48
53
|
*/
|
|
49
|
-
export default function timelineRoute(basePath, plant, operatorOptions) {
|
|
54
|
+
export default function timelineRoute(basePath, plant, operatorOptions, decorate) {
|
|
50
55
|
const gate = timelineOperator(operatorOptions);
|
|
51
56
|
return [
|
|
52
57
|
route('GET', `${basePath}/machines/:machineId/segments`, async (req, res, params, query) => {
|
|
@@ -62,7 +67,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
|
|
|
62
67
|
if (query.to) {
|
|
63
68
|
options.to = query.to;
|
|
64
69
|
}
|
|
65
|
-
const rows = await result.machine.timeline.list(options);
|
|
70
|
+
const rows = await decorate(params.machineId, await result.machine.timeline.list(options));
|
|
66
71
|
jsonResponse({ items: rows.map(segmentJson) }).send(res);
|
|
67
72
|
}),
|
|
68
73
|
route('PATCH', `${basePath}/machines/:machineId/segments`, async (req, res, params) => {
|
|
@@ -74,7 +79,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
|
|
|
74
79
|
try {
|
|
75
80
|
const raw = await readBody(req);
|
|
76
81
|
const parsed = JSON.parse(raw);
|
|
77
|
-
if (await rejectUnknownTags(result.machine.timeline, parsed, params.machineId, res)) {
|
|
82
|
+
if (await rejectUnknownTags(result.machine.timeline, parsed, params.machineId, res, decorate)) {
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
85
|
await handleOperatorWrite(gate, parsed, async (audit) => {
|
|
@@ -94,7 +99,7 @@ export default function timelineRoute(basePath, plant, operatorOptions) {
|
|
|
94
99
|
jsonResponse({ items: [] }).send(res);
|
|
95
100
|
return;
|
|
96
101
|
}
|
|
97
|
-
const rows = await result.machine.timeline.pending();
|
|
102
|
+
const rows = await decorate(params.machineId, await result.machine.timeline.pending());
|
|
98
103
|
const items = rows.map(({ id, name, start_time: startTime, end_time: endTime, duration, options }) => {
|
|
99
104
|
return {
|
|
100
105
|
id,
|
|
@@ -22,18 +22,53 @@ function segmentPayload(segment) {
|
|
|
22
22
|
return data;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
async function emitSegment(sse, event, decorate, machineId) {
|
|
26
|
+
if (event.type === 'created' && event.segment) {
|
|
27
|
+
const [row] = await decorate(machineId, [event.segment]);
|
|
28
|
+
sse.emit('segment_created', segmentPayload(row));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (event.type === 'resolved' && event.segment) {
|
|
32
|
+
const [row] = await decorate(machineId, [event.segment]);
|
|
33
|
+
sse.emit('segment_resolved', segmentPayload(row));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function emitRequest(sse, event, decorate, machineId) {
|
|
38
|
+
if (event.type === 'created' && event.request) {
|
|
39
|
+
const [row] = await decorate(machineId, [event.request]);
|
|
40
|
+
const start = row.start_time || row.startTime;
|
|
41
|
+
const end = row.end_time || row.endTime;
|
|
42
|
+
sse.emit('request_created', {
|
|
43
|
+
id: row.id,
|
|
44
|
+
segment: {
|
|
45
|
+
name: row.name,
|
|
46
|
+
start: start.toISOString(),
|
|
47
|
+
end: end.toISOString(),
|
|
48
|
+
duration: row.duration
|
|
49
|
+
},
|
|
50
|
+
options: row.options
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (event.type === 'resolved' && event.request) {
|
|
55
|
+
sse.emit('request_resolved', { id: event.request.id });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
/**
|
|
26
60
|
* Timeline SSE routes for segments and label requests.
|
|
27
61
|
*
|
|
28
62
|
* @param {string} basePath - base URL path
|
|
29
63
|
* @param {object} plant - plant domain object
|
|
30
64
|
* @param {function} clock - time provider
|
|
65
|
+
* @param {function} decorate - (machineId, rows) => rows
|
|
31
66
|
* @returns {array} route objects
|
|
32
67
|
*
|
|
33
68
|
* @example
|
|
34
69
|
* timelineStream('/api/v1', plant, clock);
|
|
35
70
|
*/
|
|
36
|
-
export default function timelineStream(basePath, plant, clock) {
|
|
71
|
+
export default function timelineStream(basePath, plant, clock, decorate) {
|
|
37
72
|
return [
|
|
38
73
|
route('GET', `${basePath}/machines/:machineId/segments/stream`, (req, res, params) => {
|
|
39
74
|
const sse = sseResponse(res, clock);
|
|
@@ -44,11 +79,7 @@ export default function timelineStream(basePath, plant, clock) {
|
|
|
44
79
|
return;
|
|
45
80
|
}
|
|
46
81
|
const subscription = result.machine.timeline.stream((event) => {
|
|
47
|
-
|
|
48
|
-
sse.emit('segment_created', segmentPayload(event.segment));
|
|
49
|
-
} else if (event.type === 'resolved' && event.segment) {
|
|
50
|
-
sse.emit('segment_resolved', segmentPayload(event.segment));
|
|
51
|
-
}
|
|
82
|
+
return emitSegment(sse, event, decorate, params.machineId);
|
|
52
83
|
});
|
|
53
84
|
const heartbeat = setInterval(() => {
|
|
54
85
|
sse.heartbeat();
|
|
@@ -67,23 +98,7 @@ export default function timelineStream(basePath, plant, clock) {
|
|
|
67
98
|
return;
|
|
68
99
|
}
|
|
69
100
|
const subscription = result.machine.timeline.stream((event) => {
|
|
70
|
-
|
|
71
|
-
const reqItem = event.request;
|
|
72
|
-
const start = reqItem.start_time || reqItem.startTime;
|
|
73
|
-
const end = reqItem.end_time || reqItem.endTime;
|
|
74
|
-
sse.emit('request_created', {
|
|
75
|
-
id: reqItem.id,
|
|
76
|
-
segment: {
|
|
77
|
-
name: reqItem.name,
|
|
78
|
-
start: start.toISOString(),
|
|
79
|
-
end: end.toISOString(),
|
|
80
|
-
duration: reqItem.duration
|
|
81
|
-
},
|
|
82
|
-
options: reqItem.options
|
|
83
|
-
});
|
|
84
|
-
} else if (event.type === 'resolved' && event.request) {
|
|
85
|
-
sse.emit('request_resolved', { id: event.request.id });
|
|
86
|
-
}
|
|
101
|
+
return emitRequest(sse, event, decorate, params.machineId);
|
|
87
102
|
});
|
|
88
103
|
const heartbeat = setInterval(() => {
|
|
89
104
|
sse.heartbeat();
|
|
@@ -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
|
}
|
|
@@ -65,19 +65,23 @@ export default function segmentStateMemory(store) {
|
|
|
65
65
|
},
|
|
66
66
|
retag(machineId, start, tagsJson, propertiesJson) {
|
|
67
67
|
const row = findRow(store, machineId, start);
|
|
68
|
-
if (row) {
|
|
69
|
-
|
|
70
|
-
row.properties = propertiesJson;
|
|
68
|
+
if (!row) {
|
|
69
|
+
return 0;
|
|
71
70
|
}
|
|
71
|
+
row.tags = tagsJson;
|
|
72
|
+
row.properties = propertiesJson;
|
|
73
|
+
return 1;
|
|
72
74
|
},
|
|
73
75
|
resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
|
|
74
76
|
const row = findRow(store, machineId, startKey);
|
|
75
|
-
if (row) {
|
|
76
|
-
|
|
77
|
-
row.properties = propertiesJson;
|
|
78
|
-
row.resolved = true;
|
|
79
|
-
row.consumed = false;
|
|
77
|
+
if (!row) {
|
|
78
|
+
return 0;
|
|
80
79
|
}
|
|
80
|
+
row.tags = tagsJson;
|
|
81
|
+
row.properties = propertiesJson;
|
|
82
|
+
row.resolved = true;
|
|
83
|
+
row.consumed = false;
|
|
84
|
+
return 1;
|
|
81
85
|
}
|
|
82
86
|
};
|
|
83
87
|
}
|
|
@@ -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
|
}
|
|
@@ -33,16 +33,18 @@ export default function segmentStatePg(pool) {
|
|
|
33
33
|
return result.rows;
|
|
34
34
|
},
|
|
35
35
|
async retag(machineId, start, tagsJson, propertiesJson) {
|
|
36
|
-
await pool.query(
|
|
36
|
+
const result = await pool.query(
|
|
37
37
|
'UPDATE segments SET tags = $1, properties = $2 WHERE machine = $3 AND start_time = $4',
|
|
38
38
|
[tagsJson, propertiesJson, machineId, start]
|
|
39
39
|
);
|
|
40
|
+
return result.rowCount;
|
|
40
41
|
},
|
|
41
42
|
async resolveRequest(machineId, startKey, tagsJson, propertiesJson) {
|
|
42
|
-
await pool.query(
|
|
43
|
+
const result = await pool.query(
|
|
43
44
|
'UPDATE segments SET tags = $1, properties = $2, resolved = TRUE, consumed = FALSE WHERE machine = $3 AND start_time = $4',
|
|
44
45
|
[tagsJson, propertiesJson, machineId, startKey]
|
|
45
46
|
);
|
|
47
|
+
return result.rowCount;
|
|
46
48
|
}
|
|
47
49
|
};
|
|
48
50
|
}
|