@yarkivaev/scada 2.3.60 → 2.3.61
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/README.md +14 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/application/siteServer.js +5 -3
- package/src/application/sync/siteSync.js +102 -0
- package/src/application/sync/siteSyncBind.js +64 -0
- package/src/application/sync/siteSyncRows.js +91 -0
- package/src/application/sync/siteSyncSites.js +81 -0
- package/src/application/sync/siteSyncTargets.js +98 -0
- package/src/infrastructure/http/plant/routes/syncRoute.js +50 -0
- package/src/infrastructure/persistence/memory/alerts.js +12 -1
- package/src/infrastructure/persistence/pg/alerts.js +16 -1
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ Main entry (`import { … } from '@yarkivaev/scada'`):
|
|
|
91
91
|
| `timeline`, `alerts`, `alert`, `acknowledgedAlert` | Timeline and alerting |
|
|
92
92
|
| `plantApi`, `plantServer`, `siteServer` | HTTP composition |
|
|
93
93
|
| `exportQuery`, `exportStream`, `exportSink`, `exportJob` | Generic export ports over plantApi / `@yarkivaev/scada/client` |
|
|
94
|
+
| `siteSync`, `siteSyncSites`, `siteSyncBind` | Pull kinds from an allowlisted remote plantApi into local persistence |
|
|
94
95
|
| `metricsPlant`, `shopWithTimeline`, `machineInPlant` | Plant wiring helpers |
|
|
95
96
|
| `supervisorSink`, `readDeploymentConfig` | STOMP ingest + PG persistence |
|
|
96
97
|
| `edgeApi`, `stateHttpClient`, `metricsSensor` | Edge HTTP read/write |
|
|
@@ -125,6 +126,19 @@ const job = exportJob({ query, transform: (rows) => rows, sink });
|
|
|
125
126
|
await job.run({ kind: 'segments', machine: 'furnace-α', from, to });
|
|
126
127
|
```
|
|
127
128
|
|
|
129
|
+
`POST /api/v1/sync` on any `siteServer` pulls `segments` / `operations` / `measurements` / `alerts` from `SYNC_SITES` / `EDGE_SITES` / `CENTRAL_PLANT_URL`. Body: `{ site, from, to, machines?, kinds? }`. Optional `SYNC_TOKEN`. Does not republish to RabbitMQ.
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
import { siteSync } from '@yarkivaev/scada';
|
|
133
|
+
|
|
134
|
+
await siteSync({ sites, queryFor, targets }).run({
|
|
135
|
+
site: 'edge-icht-1',
|
|
136
|
+
from,
|
|
137
|
+
to,
|
|
138
|
+
kinds: ['segments']
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
128
142
|
Downstream plant packages should pin a released tag (e.g. `#v2.3.46`).
|
|
129
143
|
|
|
130
144
|
Alert and HMI copy are inject-only: pass `translations` into `siteServer` / `plantServer` / `alertPipeline` config, and override `TAG_CATALOG_PATH` or `plantApi({ tagCatalog })` for site taxonomy. Defaults ship empty or English stubs.
|
package/index.js
CHANGED
|
@@ -80,3 +80,7 @@ export { default as exportQuery } from './src/application/export/exportQuery.js'
|
|
|
80
80
|
export { default as exportStream } from './src/application/export/exportStream.js';
|
|
81
81
|
export { default as exportSink } from './src/application/export/exportSink.js';
|
|
82
82
|
export { default as exportJob } from './src/application/export/exportJob.js';
|
|
83
|
+
export { default as siteSync } from './src/application/sync/siteSync.js';
|
|
84
|
+
export { default as siteSyncSites } from './src/application/sync/siteSyncSites.js';
|
|
85
|
+
export { default as siteSyncTargets } from './src/application/sync/siteSyncTargets.js';
|
|
86
|
+
export { default as siteSyncBind } from './src/application/sync/siteSyncBind.js';
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import { metricsSinkFromPool } from '../infrastructure/persistence/pg/metrics.js
|
|
|
10
10
|
import startTelemetryIngest from './siteTelemetry.js';
|
|
11
11
|
import timelineOperatorFromEnv from './timelineOperatorFromEnv.js';
|
|
12
12
|
import { buildSiteOperatorCatalog } from './siteOperatorCatalog.js';
|
|
13
|
+
import siteSyncBind from './sync/siteSyncBind.js';
|
|
13
14
|
|
|
14
15
|
function stompFromEnv(env) {
|
|
15
16
|
return {
|
|
@@ -100,10 +101,10 @@ function plantFactoryWithOperations(plantFactory, ops, sink) {
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
function siteExtraRoutes(catalog, extraRoutes) {
|
|
104
|
+
function siteExtraRoutes(catalog, extraRoutes, syncRoutes) {
|
|
104
105
|
return (path, plant, clock) => {
|
|
105
106
|
const userExtra = extraRoutes ? extraRoutes(path, plant, clock) : [];
|
|
106
|
-
return [...catalog.routes, ...userExtra];
|
|
107
|
+
return [...catalog.routes, ...syncRoutes, ...userExtra];
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -145,6 +146,7 @@ export default async function siteServer(config) {
|
|
|
145
146
|
if (catalog.sync) {
|
|
146
147
|
await catalog.sync.start();
|
|
147
148
|
}
|
|
149
|
+
const syncRoutes = siteSyncBind(env, sink, ops, { topic: config.topic, basePath });
|
|
148
150
|
const plant = await plantServer({
|
|
149
151
|
port: config.port || parseInt(env.PORT || '3000', 10),
|
|
150
152
|
basePath,
|
|
@@ -152,7 +154,7 @@ export default async function siteServer(config) {
|
|
|
152
154
|
requirePool: config.requirePool,
|
|
153
155
|
stomp: stompFromEnv(env),
|
|
154
156
|
plantFactory: plantFactoryWithOperations(config.plantFactory, ops, sink),
|
|
155
|
-
extraRoutes: siteExtraRoutes(catalog, config.extraRoutes),
|
|
157
|
+
extraRoutes: siteExtraRoutes(catalog, config.extraRoutes, syncRoutes),
|
|
156
158
|
timelineOperator: timelineOperatorFromEnv(catalog, env, config),
|
|
157
159
|
operationDecisions: catalog.decisions,
|
|
158
160
|
owners: config.owners,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { listedItems } from './siteSyncRows.js';
|
|
2
|
+
import { siteById } from './siteSyncSites.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pulls selected kinds from a remote plantApi into local targets.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} ports - { sites, queryFor, targets }
|
|
8
|
+
* @returns {object} frozen sync with run(request)
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const sync = siteSync({ sites, queryFor, targets });
|
|
12
|
+
* await sync.run({ site: 'edge-icht-1', from, to, machines: ['icht1'], kinds: ['segments'] });
|
|
13
|
+
*/
|
|
14
|
+
function pull(query, kind, machine, range) {
|
|
15
|
+
if (kind === 'segments') {
|
|
16
|
+
return query.segments(machine, range);
|
|
17
|
+
}
|
|
18
|
+
if (kind === 'operations') {
|
|
19
|
+
return query.operations(machine, range);
|
|
20
|
+
}
|
|
21
|
+
if (kind === 'measurements') {
|
|
22
|
+
return query.measurements(machine, range);
|
|
23
|
+
}
|
|
24
|
+
if (kind === 'alerts') {
|
|
25
|
+
return query.alerts(machine, range);
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`site sync cannot pull unknown kind ${kind}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function kindsOf(request, targets) {
|
|
31
|
+
const selected = request.kinds && request.kinds.length > 0 ? request.kinds : Object.keys(targets);
|
|
32
|
+
selected.forEach((kind) => {
|
|
33
|
+
if (!targets[kind]) {
|
|
34
|
+
throw new Error(`site sync unknown kind ${kind}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return selected;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function machinesOf(request, site, query) {
|
|
41
|
+
if (request.machines && request.machines.length > 0) {
|
|
42
|
+
return Promise.resolve(request.machines);
|
|
43
|
+
}
|
|
44
|
+
if (site.machines && site.machines.length > 0) {
|
|
45
|
+
return Promise.resolve(site.machines);
|
|
46
|
+
}
|
|
47
|
+
return query.machines().then((body) => {
|
|
48
|
+
return listedItems(body).map((item) => {
|
|
49
|
+
return item.id;
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function zero(kinds) {
|
|
55
|
+
return Object.fromEntries(kinds.map((kind) => {
|
|
56
|
+
return [kind, 0];
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function jobsOf(machines, kinds) {
|
|
61
|
+
return machines.flatMap((machine) => {
|
|
62
|
+
return kinds.map((kind) => {
|
|
63
|
+
return { machine, kind };
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function add(counts, kind, added) {
|
|
69
|
+
return { ...counts, [kind]: counts[kind] + added };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeKind(query, targets, job, range) {
|
|
73
|
+
return pull(query, job.kind, job.machine, range).then((body) => {
|
|
74
|
+
return targets[job.kind].write(job.machine, body);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fill(query, targets, kinds, machines, range) {
|
|
79
|
+
return jobsOf(machines, kinds).reduce((chain, job) => {
|
|
80
|
+
return chain.then((counts) => {
|
|
81
|
+
return writeKind(query, targets, job, range).then((added) => {
|
|
82
|
+
return add(counts, job.kind, added);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}, Promise.resolve(zero(kinds)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function siteSync(ports) {
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
async run(request) {
|
|
91
|
+
if (!request.from || !request.to) {
|
|
92
|
+
throw new Error('site sync requires from and to');
|
|
93
|
+
}
|
|
94
|
+
const site = siteById(ports.sites, request.site);
|
|
95
|
+
const query = ports.queryFor(site);
|
|
96
|
+
const kinds = kindsOf(request, ports.targets);
|
|
97
|
+
const machines = await machinesOf(request, site, query);
|
|
98
|
+
const counts = await fill(query, ports.targets, kinds, machines, request);
|
|
99
|
+
return { site: site.id, machines, counts };
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { clickhouseSink } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import exportQuery from '../export/exportQuery.js';
|
|
3
|
+
import scadaClient from '../../infrastructure/client/scadaClient.js';
|
|
4
|
+
import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
|
|
5
|
+
import siteSync from './siteSync.js';
|
|
6
|
+
import siteSyncSites from './siteSyncSites.js';
|
|
7
|
+
import siteSyncTargets from './siteSyncTargets.js';
|
|
8
|
+
import syncRoute from '../../infrastructure/http/plant/routes/syncRoute.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wires siteSync routes from env, sink persistence, and an optional topic port.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} env - process environment
|
|
14
|
+
* @param {object} sink - supervisor sink with pool and dataAccess
|
|
15
|
+
* @param {object} operations - operations port
|
|
16
|
+
* @param {object} [extras] - topic(machine, key), basePath
|
|
17
|
+
* @returns {object[]} plant extra routes, empty when no sites are configured
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* siteSyncBind(env, sink, ops, { topic, basePath: '/api/v1' });
|
|
21
|
+
*/
|
|
22
|
+
function fetchWithToken(token) {
|
|
23
|
+
if (!token) {
|
|
24
|
+
return fetch;
|
|
25
|
+
}
|
|
26
|
+
return (url, options) => {
|
|
27
|
+
const headers = { ...(options && options.headers), Authorization: `Bearer ${token}` };
|
|
28
|
+
return fetch(url, { ...options, headers });
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function metricsOf(sink, env) {
|
|
33
|
+
if (env.SINK_DB_PROFILE !== 'central') {
|
|
34
|
+
return metricsSinkFromPool(sink.pool);
|
|
35
|
+
}
|
|
36
|
+
const url = env.CLICKHOUSE_URL
|
|
37
|
+
|| (env.CLICKHOUSE_HOST ? `http://${env.CLICKHOUSE_HOST}:8123` : undefined);
|
|
38
|
+
if (!url) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return clickhouseSink(url, 'scada.metrics');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function queryFor(site) {
|
|
45
|
+
return exportQuery(scadaClient(site.url, fetchWithToken(site.token)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function siteSyncBind(env, sink, operations, extras) {
|
|
49
|
+
const sites = siteSyncSites(env);
|
|
50
|
+
if (sites.length === 0) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const extra = extras || {};
|
|
54
|
+
const targets = siteSyncTargets({
|
|
55
|
+
postgres: env.POSTGRES_URL,
|
|
56
|
+
pool: sink.pool,
|
|
57
|
+
operations,
|
|
58
|
+
alerts: sink.dataAccess.alerts,
|
|
59
|
+
metrics: metricsOf(sink, env),
|
|
60
|
+
topic: extra.topic
|
|
61
|
+
});
|
|
62
|
+
const sync = siteSync({ sites, queryFor, targets });
|
|
63
|
+
return syncRoute(extra.basePath || '/api/v1', sync, env.SYNC_TOKEN);
|
|
64
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps plant API JSON into local persistence rows for site sync.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* segmentRow('icht1', { name: 'on', start: '2026-01-01T00:00:00.000Z', end: '...', duration: 60 });
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps one segment API item onto the segments table shape.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} machine - machine id
|
|
12
|
+
* @param {object} item - plant API segment
|
|
13
|
+
* @returns {object} persistence row
|
|
14
|
+
*/
|
|
15
|
+
export function segmentRow(machine, item) {
|
|
16
|
+
return {
|
|
17
|
+
machine,
|
|
18
|
+
kind: item.kind || 'phase',
|
|
19
|
+
name: item.name,
|
|
20
|
+
start_time: item.start || item.start_time,
|
|
21
|
+
end_time: item.end || item.end_time,
|
|
22
|
+
duration: item.duration,
|
|
23
|
+
options: item.options,
|
|
24
|
+
tags: item.tags,
|
|
25
|
+
properties: item.properties,
|
|
26
|
+
resolved: item.resolved !== false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maps one operation API item onto the operations port shape.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} machine - machine id fallback
|
|
34
|
+
* @param {object} item - plant API operation
|
|
35
|
+
* @returns {object} persistence item
|
|
36
|
+
*/
|
|
37
|
+
export function operationRow(machine, item) {
|
|
38
|
+
return {
|
|
39
|
+
machine: item.machine || machine,
|
|
40
|
+
key: item.external_key || item.key,
|
|
41
|
+
occurred_at: item.occurred_at,
|
|
42
|
+
kind: item.kind,
|
|
43
|
+
payload: item.payload || {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maps measurement series onto metrics sink records via a topic port.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} machine - machine id
|
|
51
|
+
* @param {object} body - plant API { items: [{ key, values }] }
|
|
52
|
+
* @param {function} topic - (machine, key) => MQTT/CH topic
|
|
53
|
+
* @returns {object[]} records with topic, ts, value
|
|
54
|
+
*/
|
|
55
|
+
export function measurementRows(machine, body, topic) {
|
|
56
|
+
const items = body.items || [];
|
|
57
|
+
return items.flatMap((series) => {
|
|
58
|
+
const dest = topic(machine, series.key);
|
|
59
|
+
return (series.values || []).map((point) => {
|
|
60
|
+
return { topic: dest, ts: point.timestamp, value: point.value };
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Maps one alert API item onto the alerts table shape.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} machine - machine id
|
|
69
|
+
* @param {object} item - plant API alert
|
|
70
|
+
* @returns {object} persistence row
|
|
71
|
+
*/
|
|
72
|
+
export function alertRow(machine, item) {
|
|
73
|
+
return {
|
|
74
|
+
name: item.name,
|
|
75
|
+
message: item.message,
|
|
76
|
+
machine,
|
|
77
|
+
severity: item.severity || 'warning',
|
|
78
|
+
timestamp: item.timestamp,
|
|
79
|
+
acknowledged: item.acknowledged === true
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listedItems(body) {
|
|
84
|
+
if (Array.isArray(body)) {
|
|
85
|
+
return body;
|
|
86
|
+
}
|
|
87
|
+
if (body && Array.isArray(body.items)) {
|
|
88
|
+
return body.items;
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the allowlist of remote plant APIs a site may pull from.
|
|
3
|
+
*
|
|
4
|
+
* Reads SYNC_SITES, EDGE_SITES, and CENTRAL_PLANT_URL. First id wins.
|
|
5
|
+
*
|
|
6
|
+
* @param {NodeJS.ProcessEnv} env - process environment
|
|
7
|
+
* @returns {object[]} frozen sites with id, url, optional token and machines
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* siteSyncSites({ SYNC_SITES: '[{"id":"edge-icht-1","url":"http://edge/api/v1"}]' });
|
|
11
|
+
*/
|
|
12
|
+
function parseJson(raw, label) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch (cause) {
|
|
16
|
+
throw new Error(`${label} must be valid JSON: ${cause.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function asSite(raw, index, label) {
|
|
21
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
22
|
+
throw new Error(`${label}[${index}] must be an object`);
|
|
23
|
+
}
|
|
24
|
+
const url = raw.url || raw.baseUrl;
|
|
25
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
26
|
+
throw new Error(`${label}[${index}] requires url`);
|
|
27
|
+
}
|
|
28
|
+
const id = raw.id || (Array.isArray(raw.machines) && raw.machines[0]) || `${label}-${index}`;
|
|
29
|
+
const site = { id, url };
|
|
30
|
+
if (typeof raw.token === 'string' && raw.token.length > 0) {
|
|
31
|
+
site.token = raw.token;
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(raw.machines) && raw.machines.length > 0) {
|
|
34
|
+
site.machines = raw.machines;
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze(site);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseList(raw, label) {
|
|
40
|
+
const parsed = parseJson(raw, label);
|
|
41
|
+
if (!Array.isArray(parsed)) {
|
|
42
|
+
throw new Error(`${label} must be a JSON array`);
|
|
43
|
+
}
|
|
44
|
+
return parsed.map((item, index) => {
|
|
45
|
+
return asSite(item, index, label);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function merge(lists) {
|
|
50
|
+
const seen = new Map();
|
|
51
|
+
lists.flat().forEach((site) => {
|
|
52
|
+
if (!seen.has(site.id)) {
|
|
53
|
+
seen.set(site.id, site);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return [...seen.values()];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function siteSyncSites(env) {
|
|
60
|
+
const lists = [];
|
|
61
|
+
if (typeof env.SYNC_SITES === 'string' && env.SYNC_SITES.length > 0) {
|
|
62
|
+
lists.push(parseList(env.SYNC_SITES, 'SYNC_SITES'));
|
|
63
|
+
}
|
|
64
|
+
if (typeof env.EDGE_SITES === 'string' && env.EDGE_SITES.length > 0) {
|
|
65
|
+
lists.push(parseList(env.EDGE_SITES, 'EDGE_SITES'));
|
|
66
|
+
}
|
|
67
|
+
if (typeof env.CENTRAL_PLANT_URL === 'string' && env.CENTRAL_PLANT_URL.length > 0) {
|
|
68
|
+
lists.push([Object.freeze({ id: 'central', url: env.CENTRAL_PLANT_URL })]);
|
|
69
|
+
}
|
|
70
|
+
return merge(lists);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function siteById(sites, id) {
|
|
74
|
+
const found = sites.find((site) => {
|
|
75
|
+
return site.id === id;
|
|
76
|
+
});
|
|
77
|
+
if (!found) {
|
|
78
|
+
throw new Error(`site sync unknown site ${id}`);
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { postgresSink } from '@yarkivaev/source-to-sink';
|
|
2
|
+
import {
|
|
3
|
+
alertRow,
|
|
4
|
+
listedItems,
|
|
5
|
+
measurementRows,
|
|
6
|
+
operationRow,
|
|
7
|
+
segmentRow
|
|
8
|
+
} from './siteSyncRows.js';
|
|
9
|
+
import {
|
|
10
|
+
segmentColumns,
|
|
11
|
+
segmentConflict,
|
|
12
|
+
segmentUpdateColumns
|
|
13
|
+
} from '../../infrastructure/ingest/pipelines/segmentPipeline.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Default local write targets for site sync kinds.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} ports - pool, postgres, operations, alerts, metrics, topic
|
|
19
|
+
* @returns {object} kind → { write(machine, body) }
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* siteSyncTargets({ operations, postgres, pool, alerts, metrics, topic });
|
|
23
|
+
*/
|
|
24
|
+
function segmentTarget(postgres, pool) {
|
|
25
|
+
const sink = postgresSink(postgres, 'segments', segmentColumns, {
|
|
26
|
+
pool,
|
|
27
|
+
conflict: segmentConflict,
|
|
28
|
+
update: segmentUpdateColumns
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
async write(machine, body) {
|
|
32
|
+
const rows = listedItems(body).map((item) => {
|
|
33
|
+
return segmentRow(machine, item);
|
|
34
|
+
});
|
|
35
|
+
if (rows.length > 0) {
|
|
36
|
+
await sink.write(rows);
|
|
37
|
+
}
|
|
38
|
+
return rows.length;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function operationTarget(operations) {
|
|
44
|
+
return {
|
|
45
|
+
async write(machine, body) {
|
|
46
|
+
const rows = listedItems(body).map((item) => {
|
|
47
|
+
return operationRow(machine, item);
|
|
48
|
+
});
|
|
49
|
+
if (rows.length > 0) {
|
|
50
|
+
await operations.upsertMany(rows);
|
|
51
|
+
}
|
|
52
|
+
return rows.length;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function measurementTarget(metrics, topic) {
|
|
58
|
+
return {
|
|
59
|
+
async write(machine, body) {
|
|
60
|
+
const rows = measurementRows(machine, body, topic);
|
|
61
|
+
if (rows.length > 0) {
|
|
62
|
+
await metrics.write(rows);
|
|
63
|
+
}
|
|
64
|
+
return rows.length;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function alertTarget(alerts) {
|
|
70
|
+
return {
|
|
71
|
+
async write(machine, body) {
|
|
72
|
+
const rows = listedItems(body).map((item) => {
|
|
73
|
+
return alertRow(machine, item);
|
|
74
|
+
});
|
|
75
|
+
await Promise.all(rows.map((row) => {
|
|
76
|
+
return alerts.put(row);
|
|
77
|
+
}));
|
|
78
|
+
return rows.length;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function siteSyncTargets(ports) {
|
|
84
|
+
const targets = {};
|
|
85
|
+
if (ports.postgres && ports.pool) {
|
|
86
|
+
targets.segments = segmentTarget(ports.postgres, ports.pool);
|
|
87
|
+
}
|
|
88
|
+
if (ports.operations) {
|
|
89
|
+
targets.operations = operationTarget(ports.operations);
|
|
90
|
+
}
|
|
91
|
+
if (ports.metrics && ports.topic) {
|
|
92
|
+
targets.measurements = measurementTarget(ports.metrics, ports.topic);
|
|
93
|
+
}
|
|
94
|
+
if (ports.alerts && typeof ports.alerts.put === 'function') {
|
|
95
|
+
targets.alerts = alertTarget(ports.alerts);
|
|
96
|
+
}
|
|
97
|
+
return Object.freeze(targets);
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { errorResponse, jsonResponse, readBody, route } from '@yarkivaev/simple-server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* POST /sync — pull selected kinds from an allowlisted remote plantApi.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} basePath - API prefix
|
|
7
|
+
* @param {object} sync - siteSync port with run(request)
|
|
8
|
+
* @param {string} [token] - optional Bearer token
|
|
9
|
+
* @returns {object[]} routes
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* syncRoute('/api/v1', sync, process.env.SYNC_TOKEN);
|
|
13
|
+
*/
|
|
14
|
+
function rejectAuth(token, req, res) {
|
|
15
|
+
if (!token) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
if (req.headers.authorization === `Bearer ${token}`) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
errorResponse('UNAUTHORIZED', 'site sync token is required', 401).send(res);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rejectScope(parsed, res) {
|
|
26
|
+
if (parsed.site && parsed.from && parsed.to) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
errorResponse('BAD_REQUEST', 'site sync requires site from and to', 400).send(res);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function syncRoute(basePath, sync, token) {
|
|
34
|
+
return [
|
|
35
|
+
route('POST', `${basePath}/sync`, async (req, res) => {
|
|
36
|
+
if (rejectAuth(token, req, res)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const parsed = JSON.parse(await readBody(req));
|
|
40
|
+
if (rejectScope(parsed, res)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
jsonResponse(await sync.run(parsed)).send(res);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
errorResponse('BAD_REQUEST', error.message, 400).send(res);
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
];
|
|
50
|
+
}
|
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
* In-memory alert hydration port for tests and local runs.
|
|
3
3
|
*
|
|
4
4
|
* @param {object} store - shared mutable store with alerts array
|
|
5
|
-
* @returns {object} alerts port with listUnacknowledged
|
|
5
|
+
* @returns {object} alerts port with put and listUnacknowledged
|
|
6
6
|
*/
|
|
7
7
|
export default function alertsStateMemory(store) {
|
|
8
8
|
return {
|
|
9
|
+
put(row) {
|
|
10
|
+
const found = store.alerts.find((item) => {
|
|
11
|
+
return item.machine === row.machine && item.timestamp === row.timestamp && item.name === row.name;
|
|
12
|
+
});
|
|
13
|
+
if (found) {
|
|
14
|
+
return found;
|
|
15
|
+
}
|
|
16
|
+
const created = { id: store.alerts.length + 1, ...row };
|
|
17
|
+
store.alerts.push(created);
|
|
18
|
+
return created;
|
|
19
|
+
},
|
|
9
20
|
listUnacknowledged(filters) {
|
|
10
21
|
let rows = store.alerts.filter((row) => {
|
|
11
22
|
return row.acknowledged === false;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* PostgreSQL alert hydration port for supervisor-sink and plant STOMP alerts.
|
|
3
3
|
*
|
|
4
4
|
* @param {object} pool - pg pool
|
|
5
|
-
* @returns {object} alerts port with listUnacknowledged
|
|
5
|
+
* @returns {object} alerts port with put and listUnacknowledged
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* const store = alertsStatePg(pool);
|
|
@@ -10,6 +10,21 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export default function alertsStatePg(pool) {
|
|
12
12
|
return {
|
|
13
|
+
async put(row) {
|
|
14
|
+
const existing = await pool.query(
|
|
15
|
+
'SELECT id FROM alerts WHERE machine = $1 AND timestamp = $2 AND name = $3',
|
|
16
|
+
[row.machine, row.timestamp, row.name]
|
|
17
|
+
);
|
|
18
|
+
if (existing.rows.length > 0) {
|
|
19
|
+
return existing.rows[0];
|
|
20
|
+
}
|
|
21
|
+
const inserted = await pool.query(
|
|
22
|
+
`INSERT INTO alerts (name, message, machine, severity, timestamp, acknowledged)
|
|
23
|
+
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
|
24
|
+
[row.name, row.message, row.machine, row.severity, row.timestamp, row.acknowledged === true]
|
|
25
|
+
);
|
|
26
|
+
return inserted.rows[0];
|
|
27
|
+
},
|
|
13
28
|
async listUnacknowledged(filters) {
|
|
14
29
|
let sql = 'SELECT * FROM alerts WHERE acknowledged = FALSE';
|
|
15
30
|
const prm = [];
|