@yarkivaev/scada 2.3.73 → 2.3.74
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/infrastructure/http/plant/routes/measurementRead.js +9 -2
- package/src/infrastructure/persistence/clickhouse/holds.js +75 -0
- package/src/infrastructure/persistence/clickhouse/sensor.js +4 -0
- package/src/infrastructure/persistence/coverageHolds.js +39 -0
- package/src/infrastructure/persistence/memory/metrics.js +13 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +3 -0
- package/src/infrastructure/persistence/metricsSensor.js +19 -22
- package/src/infrastructure/persistence/pg/holdsSql.js +32 -0
- package/src/infrastructure/persistence/pg/metrics.js +21 -8
- package/src/infrastructure/persistence/pg/queryTopic.js +5 -1
- package/src/infrastructure/persistence/seriesRows.js +5 -1
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @param {object} sensor - machine sensor
|
|
5
5
|
* @param {{start: Date, end: Date}} range - query window
|
|
6
6
|
* @param {number} step - downsample step in ms
|
|
7
|
-
* @param {string} [mode] - rises or
|
|
7
|
+
* @param {string} [mode] - rises, changes, or holds
|
|
8
8
|
* @returns {Promise<Array>} series rows
|
|
9
9
|
*/
|
|
10
10
|
export default function measurementRead(sensor, range, step, mode) {
|
|
@@ -14,6 +14,9 @@ export default function measurementRead(sensor, range, step, mode) {
|
|
|
14
14
|
if (mode === 'changes') {
|
|
15
15
|
return sensor.changes(range);
|
|
16
16
|
}
|
|
17
|
+
if (mode === 'holds') {
|
|
18
|
+
return sensor.holds(range, step);
|
|
19
|
+
}
|
|
17
20
|
return sensor.measurements(range, step);
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -28,10 +31,14 @@ export default function measurementRead(sensor, range, step, mode) {
|
|
|
28
31
|
export function measurementItem(key, sensor, rows) {
|
|
29
32
|
const unit = rows.length > 0 ? rows[0].unit : '';
|
|
30
33
|
const values = rows.map((row) => {
|
|
31
|
-
|
|
34
|
+
const point = {
|
|
32
35
|
timestamp: row.timestamp.toISOString(),
|
|
33
36
|
value: row.value
|
|
34
37
|
};
|
|
38
|
+
if (row.until) {
|
|
39
|
+
point.until = row.until.toISOString();
|
|
40
|
+
}
|
|
41
|
+
return point;
|
|
35
42
|
});
|
|
36
43
|
return { key, name: sensor.name(), unit, values };
|
|
37
44
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage holds of one ClickHouse metric.
|
|
3
|
+
*
|
|
4
|
+
* Heartbeat repeats stay in one span. A value change or a silence
|
|
5
|
+
* longer than gap closes the span. Samples before the window are ignored.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* await clickhouseHolds(conn, 'm1/diam', { start, end }, 3000, 'mm');
|
|
9
|
+
*
|
|
10
|
+
* @param {object} connection - ClickHouse connection with query method
|
|
11
|
+
* @param {string} topic - metric topic
|
|
12
|
+
* @param {{start: Date, end: Date}} range - half-open window
|
|
13
|
+
* @param {number} gap - maximum silence in milliseconds
|
|
14
|
+
* @param {string} unit - measurement unit
|
|
15
|
+
* @returns {Promise<Array<{timestamp: Date, value: number, until: Date, unit: string}>>} holds
|
|
16
|
+
*/
|
|
17
|
+
function formatDateTime(date) {
|
|
18
|
+
return date.toISOString().replace('Z', '').replace('T', ' ');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HOLDS_SQL = `WITH windowed AS (
|
|
22
|
+
SELECT ts, value
|
|
23
|
+
FROM scada.metrics
|
|
24
|
+
WHERE topic = {topic:String}
|
|
25
|
+
AND ts >= {start:DateTime64(3)}
|
|
26
|
+
AND ts < {end:DateTime64(3)}
|
|
27
|
+
), lagged AS (
|
|
28
|
+
SELECT
|
|
29
|
+
ts,
|
|
30
|
+
value,
|
|
31
|
+
lagInFrame(ts, 1) OVER (ORDER BY ts) AS prev_ts,
|
|
32
|
+
lagInFrame(toNullable(value), 1) OVER (ORDER BY ts) AS prev
|
|
33
|
+
FROM windowed
|
|
34
|
+
), flagged AS (
|
|
35
|
+
SELECT
|
|
36
|
+
ts,
|
|
37
|
+
value,
|
|
38
|
+
if(
|
|
39
|
+
prev IS NULL
|
|
40
|
+
OR value != prev
|
|
41
|
+
OR toUnixTimestamp64Milli(ts) - toUnixTimestamp64Milli(prev_ts) > {gap:UInt64},
|
|
42
|
+
1,
|
|
43
|
+
0
|
|
44
|
+
) AS fresh
|
|
45
|
+
FROM lagged
|
|
46
|
+
), grouped AS (
|
|
47
|
+
SELECT
|
|
48
|
+
ts,
|
|
49
|
+
value,
|
|
50
|
+
sum(fresh) OVER (ORDER BY ts) AS grp
|
|
51
|
+
FROM flagged
|
|
52
|
+
)
|
|
53
|
+
SELECT min(ts) AS ts, max(ts) AS until, any(value) AS value
|
|
54
|
+
FROM grouped
|
|
55
|
+
GROUP BY grp
|
|
56
|
+
ORDER BY ts`;
|
|
57
|
+
|
|
58
|
+
export default async function clickhouseHolds(connection, topic, range, gap, unit) {
|
|
59
|
+
const rows = await connection.query(HOLDS_SQL, {
|
|
60
|
+
topic,
|
|
61
|
+
start: formatDateTime(range.start),
|
|
62
|
+
end: formatDateTime(range.end),
|
|
63
|
+
gap: Math.max(1, Number(gap) || 1000)
|
|
64
|
+
});
|
|
65
|
+
return rows.map((row) => {
|
|
66
|
+
return {
|
|
67
|
+
timestamp: new Date(`${row.ts}Z`),
|
|
68
|
+
value: row.value,
|
|
69
|
+
until: new Date(`${row.until}Z`),
|
|
70
|
+
unit
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { HOLDS_SQL };
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import clickhouseStreamHub from './streamHub.js';
|
|
24
24
|
import clickhouseRises from './rises.js';
|
|
25
25
|
import clickhouseChanges from './changes.js';
|
|
26
|
+
import clickhouseHolds from './holds.js';
|
|
26
27
|
import clickhouseSeries from './series.js';
|
|
27
28
|
import clickhouseLatest from './latest.js';
|
|
28
29
|
|
|
@@ -43,6 +44,9 @@ export default function clickhouseSensor(connection, topic, displayName, unit) {
|
|
|
43
44
|
changes(range) {
|
|
44
45
|
return clickhouseChanges(connection, topic, range, unit);
|
|
45
46
|
},
|
|
47
|
+
holds(range, step) {
|
|
48
|
+
return clickhouseHolds(connection, topic, range, step, unit);
|
|
49
|
+
},
|
|
46
50
|
stream(since, step, callback, clock) {
|
|
47
51
|
return clickhouseStreamHub(connection).watch(topic, since, step, callback, {
|
|
48
52
|
unit,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds heartbeat samples into coverage holds.
|
|
3
|
+
*
|
|
4
|
+
* A new span starts on the first in-window sample, a value change,
|
|
5
|
+
* or a silence longer than gap. Samples before the window are ignored.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<{ts: Date, value: *}>} rows - raw metric rows
|
|
8
|
+
* @param {number} start - window start epoch milliseconds
|
|
9
|
+
* @param {number} end - exclusive window end epoch milliseconds
|
|
10
|
+
* @param {number} gap - maximum silence in milliseconds
|
|
11
|
+
* @returns {Array<{ts: Date, value: *, until: Date}>} holds
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* coverageHolds(rows, from, to, 3000);
|
|
15
|
+
*/
|
|
16
|
+
export default function coverageHolds(rows, start, end, gap) {
|
|
17
|
+
const series = (rows || []).filter((row) => {
|
|
18
|
+
const ts = new Date(row.ts).getTime();
|
|
19
|
+
return ts >= start && ts < end;
|
|
20
|
+
}).sort((left, right) => {
|
|
21
|
+
return new Date(left.ts) - new Date(right.ts);
|
|
22
|
+
});
|
|
23
|
+
const spans = [];
|
|
24
|
+
series.forEach((row) => {
|
|
25
|
+
const ts = new Date(row.ts);
|
|
26
|
+
const last = spans[spans.length - 1];
|
|
27
|
+
if (!last) {
|
|
28
|
+
spans.push({ ts, value: row.value, until: ts });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const silence = ts.getTime() - last.until.getTime();
|
|
32
|
+
if (row.value !== last.value || silence > gap) {
|
|
33
|
+
spans.push({ ts, value: row.value, until: ts });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
last.until = ts;
|
|
37
|
+
});
|
|
38
|
+
return spans;
|
|
39
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import risingPrefixes from '../risingPrefixes.js';
|
|
2
2
|
import levelChanges from '../levelChanges.js';
|
|
3
|
+
import coverageHolds from '../coverageHolds.js';
|
|
3
4
|
import { seriesWindow, topicRows } from '../topicWindow.js';
|
|
4
5
|
|
|
5
6
|
function bucketKey(tsMs, originMs, stepMs) {
|
|
@@ -49,6 +50,15 @@ function changesOf(rows, topic, startIso, endIso) {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function holdsOf(rows, topic, startIso, endIso, stepMs) {
|
|
54
|
+
const start = new Date(startIso).getTime();
|
|
55
|
+
const end = new Date(endIso).getTime();
|
|
56
|
+
const hits = rows.filter((row) => {
|
|
57
|
+
return row.topic === topic;
|
|
58
|
+
});
|
|
59
|
+
return coverageHolds(hits, start, end, Math.max(1, Number(stepMs) || 1000));
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
/**
|
|
53
63
|
* In-memory metrics state port for tests and local runs.
|
|
54
64
|
*
|
|
@@ -77,6 +87,9 @@ export default function metricsStateMemory(store) {
|
|
|
77
87
|
changesForTopic(topic, startIso, endIso) {
|
|
78
88
|
return changesOf(store.metrics, topic, startIso, endIso);
|
|
79
89
|
},
|
|
90
|
+
holdsForTopic(topic, startIso, endIso, stepMs) {
|
|
91
|
+
return holdsOf(store.metrics, topic, startIso, endIso, stepMs);
|
|
92
|
+
},
|
|
80
93
|
pollTopic(topic, afterIso, untilIso) {
|
|
81
94
|
const after = new Date(afterIso).getTime();
|
|
82
95
|
const until = new Date(untilIso).getTime();
|
|
@@ -49,6 +49,9 @@ export function pgMetricsRead(metrics) {
|
|
|
49
49
|
changes(topic, startIso, endIso) {
|
|
50
50
|
return metrics.changesForTopic(topic, startIso, endIso);
|
|
51
51
|
},
|
|
52
|
+
holds(topic, startIso, endIso, stepMs) {
|
|
53
|
+
return metrics.holdsForTopic(topic, startIso, endIso, stepMs);
|
|
54
|
+
},
|
|
52
55
|
poll(topic, afterIso, untilIso) {
|
|
53
56
|
return metrics.pollTopic(topic, afterIso, untilIso);
|
|
54
57
|
}
|
|
@@ -64,6 +67,13 @@ export function pgMetricsRead(metrics) {
|
|
|
64
67
|
* @param {string} unit - unit string
|
|
65
68
|
* @returns {object} sensor with name current measurements stream
|
|
66
69
|
*/
|
|
70
|
+
async function windowed(read, spec) {
|
|
71
|
+
const rows = spec.extra === undefined
|
|
72
|
+
? await read[spec.method](spec.topic, spec.range.start.toISOString(), spec.range.end.toISOString())
|
|
73
|
+
: await read[spec.method](spec.topic, spec.range.start.toISOString(), spec.range.end.toISOString(), spec.extra);
|
|
74
|
+
return seriesRows(rows, spec.unit);
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
export default function metricsSensor(read, topic, displayName, unit) {
|
|
68
78
|
return {
|
|
69
79
|
name() {
|
|
@@ -81,30 +91,17 @@ export default function metricsSensor(read, topic, displayName, unit) {
|
|
|
81
91
|
unit
|
|
82
92
|
};
|
|
83
93
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
step
|
|
90
|
-
);
|
|
91
|
-
return seriesRows(rows, unit);
|
|
94
|
+
measurements(range, step) {
|
|
95
|
+
return windowed(read, { method: 'range', topic, range, extra: step, unit });
|
|
96
|
+
},
|
|
97
|
+
rises(range) {
|
|
98
|
+
return windowed(read, { method: 'rises', topic, range, extra: undefined, unit });
|
|
92
99
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
topic,
|
|
96
|
-
range.start.toISOString(),
|
|
97
|
-
range.end.toISOString()
|
|
98
|
-
);
|
|
99
|
-
return seriesRows(rows, unit);
|
|
100
|
+
changes(range) {
|
|
101
|
+
return windowed(read, { method: 'changes', topic, range, extra: undefined, unit });
|
|
100
102
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
topic,
|
|
104
|
-
range.start.toISOString(),
|
|
105
|
-
range.end.toISOString()
|
|
106
|
-
);
|
|
107
|
-
return seriesRows(rows, unit);
|
|
103
|
+
holds(range, step) {
|
|
104
|
+
return windowed(read, { method: 'holds', topic, range, extra: step, unit });
|
|
108
105
|
},
|
|
109
106
|
stream(since, step, callback, clock) {
|
|
110
107
|
return pollStream({ read, topic, since, step, callback, clock, unit });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage holds of one Postgres metrics topic.
|
|
3
|
+
*
|
|
4
|
+
* $1 topic, $2 start, $3 exclusive end, $4 gap milliseconds.
|
|
5
|
+
*
|
|
6
|
+
* @returns {string} SQL
|
|
7
|
+
*/
|
|
8
|
+
export default function holdsSql() {
|
|
9
|
+
return `WITH windowed AS (
|
|
10
|
+
SELECT ts, value FROM metrics
|
|
11
|
+
WHERE topic = $1 AND ts >= $2 AND ts < $3
|
|
12
|
+
), lagged AS (
|
|
13
|
+
SELECT ts, value,
|
|
14
|
+
LAG(ts) OVER (ORDER BY ts) AS prev_ts,
|
|
15
|
+
LAG(value) OVER (ORDER BY ts) AS prev
|
|
16
|
+
FROM windowed
|
|
17
|
+
), flagged AS (
|
|
18
|
+
SELECT ts, value,
|
|
19
|
+
CASE WHEN prev IS NULL
|
|
20
|
+
OR value IS DISTINCT FROM prev
|
|
21
|
+
OR (EXTRACT(EPOCH FROM (ts - prev_ts)) * 1000) > $4
|
|
22
|
+
THEN 1 ELSE 0 END AS fresh
|
|
23
|
+
FROM lagged
|
|
24
|
+
), grouped AS (
|
|
25
|
+
SELECT ts, value, SUM(fresh) OVER (ORDER BY ts) AS grp
|
|
26
|
+
FROM flagged
|
|
27
|
+
)
|
|
28
|
+
SELECT MIN(ts) AS ts, MAX(ts) AS until, MIN(value) AS value
|
|
29
|
+
FROM grouped
|
|
30
|
+
GROUP BY grp
|
|
31
|
+
ORDER BY ts`;
|
|
32
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import risesSql from './risesSql.js';
|
|
2
2
|
import changesSql from './changesSql.js';
|
|
3
|
+
import holdsSql from './holdsSql.js';
|
|
3
4
|
import queryTopic from './queryTopic.js';
|
|
4
5
|
import rangeSql from './rangeSql.js';
|
|
5
6
|
|
|
7
|
+
async function pollRows(pool, topic, afterIso, untilIso) {
|
|
8
|
+
const result = await pool.query(
|
|
9
|
+
'SELECT ts, value FROM metrics WHERE topic = $1 AND ts > $2 AND ts <= $3 ORDER BY ts LIMIT 100',
|
|
10
|
+
[topic, afterIso, untilIso]
|
|
11
|
+
);
|
|
12
|
+
return result.rows.map((row) => {
|
|
13
|
+
return { ts: row.ts, value: row.value };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
export default function metricsStatePg(pool) {
|
|
7
18
|
return {
|
|
8
19
|
async latestForTopic(topic) {
|
|
@@ -22,14 +33,16 @@ export default function metricsStatePg(pool) {
|
|
|
22
33
|
changesForTopic(topic, startIso, endIso) {
|
|
23
34
|
return queryTopic(pool, changesSql(), [topic, startIso, endIso]);
|
|
24
35
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
holdsForTopic(topic, startIso, endIso, stepMs) {
|
|
37
|
+
return queryTopic(pool, holdsSql(), [
|
|
38
|
+
topic,
|
|
39
|
+
startIso,
|
|
40
|
+
endIso,
|
|
41
|
+
Math.max(1, Number(stepMs) || 1000)
|
|
42
|
+
]);
|
|
43
|
+
},
|
|
44
|
+
pollTopic(topic, afterIso, untilIso) {
|
|
45
|
+
return pollRows(pool, topic, afterIso, untilIso);
|
|
33
46
|
},
|
|
34
47
|
async insertRows(items) {
|
|
35
48
|
const topics = items.map((row) => {
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
export default async function queryTopic(pool, sql, params) {
|
|
10
10
|
const result = await pool.query(sql, params);
|
|
11
11
|
return result.rows.map((row) => {
|
|
12
|
-
|
|
12
|
+
const item = { ts: row.ts, value: Number(row.value) };
|
|
13
|
+
if (row.until) {
|
|
14
|
+
item.until = row.until;
|
|
15
|
+
}
|
|
16
|
+
return item;
|
|
13
17
|
});
|
|
14
18
|
}
|
|
@@ -11,11 +11,15 @@ function parseTimestamp(ts) {
|
|
|
11
11
|
*/
|
|
12
12
|
export default function seriesRows(rows, unit) {
|
|
13
13
|
return rows.map((row) => {
|
|
14
|
-
|
|
14
|
+
const point = {
|
|
15
15
|
timestamp: parseTimestamp(row.ts),
|
|
16
16
|
value: row.value,
|
|
17
17
|
unit
|
|
18
18
|
};
|
|
19
|
+
if (row.until) {
|
|
20
|
+
point.until = parseTimestamp(row.until);
|
|
21
|
+
}
|
|
22
|
+
return point;
|
|
19
23
|
});
|
|
20
24
|
}
|
|
21
25
|
|