@yarkivaev/scada 2.3.69 → 2.3.70
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/client/machineClient.js +3 -0
- package/src/infrastructure/http/plant/routes/measurementRead.js +37 -0
- package/src/infrastructure/http/plant/routes/measurementRoute.js +27 -19
- package/src/infrastructure/persistence/clickhouse/changes.js +59 -0
- package/src/infrastructure/persistence/clickhouse/latest.js +20 -0
- package/src/infrastructure/persistence/clickhouse/rises.js +56 -0
- package/src/infrastructure/persistence/clickhouse/sensor.js +15 -37
- package/src/infrastructure/persistence/clickhouse/series.js +30 -0
- package/src/infrastructure/persistence/levelChanges.js +28 -0
- package/src/infrastructure/persistence/memory/metrics.js +32 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +6 -0
- package/src/infrastructure/persistence/metricsSensor.js +24 -10
- package/src/infrastructure/persistence/pg/changesSql.js +30 -0
- package/src/infrastructure/persistence/pg/metrics.js +13 -11
- package/src/infrastructure/persistence/pg/queryTopic.js +14 -0
- package/src/infrastructure/persistence/pg/rangeSql.js +14 -0
- package/src/infrastructure/persistence/pg/risesSql.js +29 -0
- package/src/infrastructure/persistence/risingPrefixes.js +29 -0
- package/src/infrastructure/persistence/seriesRows.js +22 -0
- package/src/infrastructure/persistence/topicWindow.js +39 -0
package/package.json
CHANGED
|
@@ -98,6 +98,9 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource,
|
|
|
98
98
|
if (options && options.step) {
|
|
99
99
|
params.set('step', String(options.step));
|
|
100
100
|
}
|
|
101
|
+
if (options && options.mode) {
|
|
102
|
+
params.set('mode', options.mode);
|
|
103
|
+
}
|
|
101
104
|
const qs = params.toString();
|
|
102
105
|
return request(`/measurements${qs ? `?${qs}` : ''}`);
|
|
103
106
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a downsampled series, rising prefixes, or level changes.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} sensor - machine sensor
|
|
5
|
+
* @param {{start: Date, end: Date}} range - query window
|
|
6
|
+
* @param {number} step - downsample step in ms
|
|
7
|
+
* @param {string} [mode] - rises or changes
|
|
8
|
+
* @returns {Promise<Array>} series rows
|
|
9
|
+
*/
|
|
10
|
+
export default function measurementRead(sensor, range, step, mode) {
|
|
11
|
+
if (mode === 'rises') {
|
|
12
|
+
return sensor.rises(range);
|
|
13
|
+
}
|
|
14
|
+
if (mode === 'changes') {
|
|
15
|
+
return sensor.changes(range);
|
|
16
|
+
}
|
|
17
|
+
return sensor.measurements(range, step);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Packs one sensor series into the measurements JSON item.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} key - sensor key
|
|
24
|
+
* @param {object} sensor - machine sensor
|
|
25
|
+
* @param {Array<{timestamp: Date, value: number, unit: string}>} rows - series
|
|
26
|
+
* @returns {{key: string, name: string, unit: string, values: Array}} item
|
|
27
|
+
*/
|
|
28
|
+
export function measurementItem(key, sensor, rows) {
|
|
29
|
+
const unit = rows.length > 0 ? rows[0].unit : '';
|
|
30
|
+
const values = rows.map((row) => {
|
|
31
|
+
return {
|
|
32
|
+
timestamp: row.timestamp.toISOString(),
|
|
33
|
+
value: row.value
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return { key, name: sensor.name(), unit, values };
|
|
37
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import machineInPlant from '../../../../application/machineInPlant.js';
|
|
2
2
|
import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
|
|
3
|
+
import measurementRead, { measurementItem } from './measurementRead.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Measurement routes factory.
|
|
@@ -13,6 +14,31 @@ import { jsonResponse, route, timeExpression } from '@yarkivaev/simple-server';
|
|
|
13
14
|
* @example
|
|
14
15
|
* const routes = measurementRoute('/api/v1', plant, clock);
|
|
15
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Collects measurement items for the requested keys and mode.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} machine - plant machine
|
|
21
|
+
* @param {object} query - HTTP query
|
|
22
|
+
* @param {function} clock - time provider
|
|
23
|
+
* @param {function} beginning - range floor
|
|
24
|
+
* @returns {Promise<Array>} items
|
|
25
|
+
*/
|
|
26
|
+
async function measure(machine, query, clock, beginning) {
|
|
27
|
+
const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
|
|
28
|
+
const keys = requested.filter((key) => {
|
|
29
|
+
return machine.sensors[key];
|
|
30
|
+
});
|
|
31
|
+
const from = timeExpression(query.from || 'now-1M', clock, beginning).resolve();
|
|
32
|
+
const to = timeExpression(query.to || 'now', clock, beginning).resolve();
|
|
33
|
+
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
34
|
+
const range = { start: from, end: to };
|
|
35
|
+
return await Promise.all(keys.map(async (key) => {
|
|
36
|
+
const sensor = machine.sensors[key];
|
|
37
|
+
const rows = await measurementRead(sensor, range, step, query.mode);
|
|
38
|
+
return measurementItem(key, sensor, rows);
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
export default function measurementRoute(basePath, plant, clock) {
|
|
17
43
|
function beginning() {
|
|
18
44
|
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
@@ -34,25 +60,7 @@ export default function measurementRoute(basePath, plant, clock) {
|
|
|
34
60
|
jsonResponse({ items: [] }).send(res);
|
|
35
61
|
return;
|
|
36
62
|
}
|
|
37
|
-
const
|
|
38
|
-
const keys = requested.filter((key) => {return machine.sensors[key]});
|
|
39
|
-
const fromExpr = query.from || 'now-1M';
|
|
40
|
-
const toExpr = query.to || 'now';
|
|
41
|
-
const from = timeExpression(fromExpr, clock, beginning).resolve();
|
|
42
|
-
const to = timeExpression(toExpr, clock, beginning).resolve();
|
|
43
|
-
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
44
|
-
const range = { start: from, end: to };
|
|
45
|
-
const promises = keys.map(async (key) => {
|
|
46
|
-
const sensor = machine.sensors[key];
|
|
47
|
-
const measurements = await sensor.measurements(range, step);
|
|
48
|
-
const unit = measurements.length > 0 ? measurements[0].unit : '';
|
|
49
|
-
const values = measurements.map((row) => {return {
|
|
50
|
-
timestamp: row.timestamp.toISOString(),
|
|
51
|
-
value: row.value
|
|
52
|
-
}});
|
|
53
|
-
return { key, name: sensor.name(), unit, values };
|
|
54
|
-
});
|
|
55
|
-
const items = await Promise.all(promises);
|
|
63
|
+
const items = await measure(machine, query, clock, beginning);
|
|
56
64
|
jsonResponse({ items }).send(res);
|
|
57
65
|
}
|
|
58
66
|
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level changes of one ClickHouse metric, including the sample held at the start.
|
|
3
|
+
*
|
|
4
|
+
* Heartbeat repeats of the same value stay in the table and out of the result.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* await clickhouseChanges(conn, 'm1/flag', { start, end }, '');
|
|
8
|
+
*
|
|
9
|
+
* @param {object} connection - ClickHouse connection with query method
|
|
10
|
+
* @param {string} topic - metric topic
|
|
11
|
+
* @param {{start: Date, end: Date}} range - inclusive window
|
|
12
|
+
* @param {string} unit - measurement unit
|
|
13
|
+
* @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} changes
|
|
14
|
+
*/
|
|
15
|
+
function formatDateTime(date) {
|
|
16
|
+
return date.toISOString().replace('Z', '').replace('T', ' ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CHANGES_SQL = `WITH prior AS (
|
|
20
|
+
SELECT ts, value
|
|
21
|
+
FROM scada.metrics
|
|
22
|
+
WHERE topic = {topic:String} AND ts < {start:DateTime64(3)}
|
|
23
|
+
ORDER BY ts DESC
|
|
24
|
+
LIMIT 1
|
|
25
|
+
), windowed AS (
|
|
26
|
+
SELECT ts, value
|
|
27
|
+
FROM scada.metrics
|
|
28
|
+
WHERE topic = {topic:String}
|
|
29
|
+
AND ts >= {start:DateTime64(3)}
|
|
30
|
+
AND ts <= {end:DateTime64(3)}
|
|
31
|
+
), series AS (
|
|
32
|
+
SELECT ts, value FROM prior
|
|
33
|
+
UNION ALL
|
|
34
|
+
SELECT ts, value FROM windowed
|
|
35
|
+
), lagged AS (
|
|
36
|
+
SELECT
|
|
37
|
+
ts,
|
|
38
|
+
value,
|
|
39
|
+
row_number() OVER (ORDER BY ts) AS n,
|
|
40
|
+
lagInFrame(value, 1, NULL) OVER (ORDER BY ts) AS prev
|
|
41
|
+
FROM series
|
|
42
|
+
)
|
|
43
|
+
SELECT if(ts < {start:DateTime64(3)}, {start:DateTime64(3)}, ts) AS ts, value
|
|
44
|
+
FROM lagged
|
|
45
|
+
WHERE n = 1 OR value != prev
|
|
46
|
+
ORDER BY ts`;
|
|
47
|
+
|
|
48
|
+
export default async function clickhouseChanges(connection, topic, range, unit) {
|
|
49
|
+
const rows = await connection.query(CHANGES_SQL, {
|
|
50
|
+
topic,
|
|
51
|
+
start: formatDateTime(range.start),
|
|
52
|
+
end: formatDateTime(range.end)
|
|
53
|
+
});
|
|
54
|
+
return rows.map((row) => {
|
|
55
|
+
return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { CHANGES_SQL };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latest sample of one ClickHouse metric topic.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} connection - ClickHouse connection with query method
|
|
5
|
+
* @param {string} topic - metric topic
|
|
6
|
+
* @param {string} unit - measurement unit
|
|
7
|
+
* @returns {Promise<{found: boolean, timestamp?: Date, value?: number, unit?: string}>} reading
|
|
8
|
+
*/
|
|
9
|
+
export default async function clickhouseLatest(connection, topic, unit) {
|
|
10
|
+
const rows = await connection.query(
|
|
11
|
+
`SELECT ts, value FROM scada.metrics
|
|
12
|
+
WHERE topic = {topic:String}
|
|
13
|
+
ORDER BY ts DESC LIMIT 1`,
|
|
14
|
+
{ topic }
|
|
15
|
+
);
|
|
16
|
+
if (rows.length === 0) {
|
|
17
|
+
return { found: false };
|
|
18
|
+
}
|
|
19
|
+
return { found: true, timestamp: new Date(`${rows[0].ts}Z`), value: rows[0].value, unit };
|
|
20
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix sum of 0→1 edges for one ClickHouse metric topic.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* await clickhouseRises(conn, 'm1/flag', { start, end }, '');
|
|
6
|
+
*
|
|
7
|
+
* @param {object} connection - ClickHouse connection with query method
|
|
8
|
+
* @param {string} topic - metric topic
|
|
9
|
+
* @param {{start: Date, end: Date}} range - half-open window
|
|
10
|
+
* @param {string} unit - measurement unit
|
|
11
|
+
* @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} prefixes
|
|
12
|
+
*/
|
|
13
|
+
function formatDateTime(date) {
|
|
14
|
+
return date.toISOString().replace('Z', '').replace('T', ' ');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RISES_SQL = `WITH prior AS (
|
|
18
|
+
SELECT ts, value
|
|
19
|
+
FROM scada.metrics
|
|
20
|
+
WHERE topic = {topic:String} AND ts < {start:DateTime64(3)}
|
|
21
|
+
ORDER BY ts DESC
|
|
22
|
+
LIMIT 1
|
|
23
|
+
), windowed AS (
|
|
24
|
+
SELECT ts, value
|
|
25
|
+
FROM scada.metrics
|
|
26
|
+
WHERE topic = {topic:String}
|
|
27
|
+
AND ts >= {start:DateTime64(3)}
|
|
28
|
+
AND ts < {end:DateTime64(3)}
|
|
29
|
+
), series AS (
|
|
30
|
+
SELECT ts, value FROM prior
|
|
31
|
+
UNION ALL
|
|
32
|
+
SELECT ts, value FROM windowed
|
|
33
|
+
), lagged AS (
|
|
34
|
+
SELECT
|
|
35
|
+
ts,
|
|
36
|
+
value,
|
|
37
|
+
lagInFrame(value, 1, NULL) OVER (ORDER BY ts) AS prev
|
|
38
|
+
FROM series
|
|
39
|
+
)
|
|
40
|
+
SELECT ts, sum(1) OVER (ORDER BY ts) AS value
|
|
41
|
+
FROM lagged
|
|
42
|
+
WHERE ts >= {start:DateTime64(3)} AND prev = 0 AND value = 1
|
|
43
|
+
ORDER BY ts`;
|
|
44
|
+
|
|
45
|
+
export default async function clickhouseRises(connection, topic, range, unit) {
|
|
46
|
+
const rows = await connection.query(RISES_SQL, {
|
|
47
|
+
topic,
|
|
48
|
+
start: formatDateTime(range.start),
|
|
49
|
+
end: formatDateTime(range.end)
|
|
50
|
+
});
|
|
51
|
+
return rows.map((row) => {
|
|
52
|
+
return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { RISES_SQL };
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @param {string} topic - Metric topic in format '{machine}/{sensor}'
|
|
12
12
|
* @param {string} displayName - Human-readable sensor name
|
|
13
13
|
* @param {string} unit - Measurement unit (e.g., 'V', 'cos(φ)')
|
|
14
|
-
* @returns {object} sensor with name, current, measurements and stream methods
|
|
14
|
+
* @returns {object} sensor with name, current, measurements, rises, changes and stream methods
|
|
15
15
|
*
|
|
16
16
|
* @example
|
|
17
17
|
* const sensor = clickhouseSensor(conn, 'm1/voltage', 'Voltage', 'V');
|
|
@@ -21,49 +21,27 @@
|
|
|
21
21
|
* sensor.stream(since, 1000, callback); // live stream
|
|
22
22
|
*/
|
|
23
23
|
import clickhouseStreamHub from './streamHub.js';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
import clickhouseRises from './rises.js';
|
|
25
|
+
import clickhouseChanges from './changes.js';
|
|
26
|
+
import clickhouseSeries from './series.js';
|
|
27
|
+
import clickhouseLatest from './latest.js';
|
|
28
28
|
|
|
29
29
|
export default function clickhouseSensor(connection, topic, displayName, unit) {
|
|
30
30
|
return {
|
|
31
31
|
name() {
|
|
32
32
|
return displayName;
|
|
33
33
|
},
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`SELECT ts, value FROM scada.metrics
|
|
37
|
-
WHERE topic = {topic:String}
|
|
38
|
-
ORDER BY ts DESC LIMIT 1`,
|
|
39
|
-
{ topic }
|
|
40
|
-
);
|
|
41
|
-
if (rows.length === 0) {
|
|
42
|
-
return { found: false };
|
|
43
|
-
}
|
|
44
|
-
return { found: true, timestamp: new Date(`${rows[0].ts}Z`), value: rows[0].value, unit };
|
|
34
|
+
current() {
|
|
35
|
+
return clickhouseLatest(connection, topic, unit);
|
|
45
36
|
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
AND ts >= toStartOfInterval({start:DateTime64(3)}, INTERVAL ${seconds} SECOND)
|
|
55
|
-
AND ts <= {end:DateTime64(3)}
|
|
56
|
-
GROUP BY ts
|
|
57
|
-
ORDER BY ts`,
|
|
58
|
-
{
|
|
59
|
-
topic,
|
|
60
|
-
start: formatDateTime(range.start),
|
|
61
|
-
end: formatDateTime(range.end)
|
|
62
|
-
}
|
|
63
|
-
);
|
|
64
|
-
return rows.map((row) => {
|
|
65
|
-
return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
|
|
66
|
-
});
|
|
37
|
+
measurements(range, step) {
|
|
38
|
+
return clickhouseSeries(connection, topic, range, step, unit);
|
|
39
|
+
},
|
|
40
|
+
rises(range) {
|
|
41
|
+
return clickhouseRises(connection, topic, range, unit);
|
|
42
|
+
},
|
|
43
|
+
changes(range) {
|
|
44
|
+
return clickhouseChanges(connection, topic, range, unit);
|
|
67
45
|
},
|
|
68
46
|
stream(since, step, callback, clock) {
|
|
69
47
|
return clickhouseStreamHub(connection).watch(topic, since, step, callback, {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downsampled ClickHouse metric series for one topic.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} connection - ClickHouse connection with query method
|
|
5
|
+
* @param {string} topic - metric topic
|
|
6
|
+
* @param {{start: Date, end: Date}} range - inclusive window
|
|
7
|
+
* @param {number} step - downsample step in ms
|
|
8
|
+
* @param {string} unit - measurement unit
|
|
9
|
+
* @returns {Promise<Array<{timestamp: Date, value: number, unit: string}>>} buckets
|
|
10
|
+
*/
|
|
11
|
+
export default async function clickhouseSeries(connection, topic, range, step, unit) {
|
|
12
|
+
const seconds = Math.max(1, Math.floor(step / 1000));
|
|
13
|
+
const start = range.start.toISOString().replace('Z', '').replace('T', ' ');
|
|
14
|
+
const end = range.end.toISOString().replace('Z', '').replace('T', ' ');
|
|
15
|
+
const rows = await connection.query(
|
|
16
|
+
`SELECT
|
|
17
|
+
toStartOfInterval(ts, INTERVAL ${seconds} SECOND) as ts,
|
|
18
|
+
anyLast(value) as value
|
|
19
|
+
FROM scada.metrics
|
|
20
|
+
WHERE topic = {topic:String}
|
|
21
|
+
AND ts >= toStartOfInterval({start:DateTime64(3)}, INTERVAL ${seconds} SECOND)
|
|
22
|
+
AND ts <= {end:DateTime64(3)}
|
|
23
|
+
GROUP BY ts
|
|
24
|
+
ORDER BY ts`,
|
|
25
|
+
{ topic, start, end }
|
|
26
|
+
);
|
|
27
|
+
return rows.map((row) => {
|
|
28
|
+
return { timestamp: new Date(`${row.ts}Z`), value: row.value, unit };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drops repeated samples from an ordered metric series.
|
|
3
|
+
*
|
|
4
|
+
* The first row is kept so a window can start from the level already held.
|
|
5
|
+
* Later rows are kept only when the numeric value changes.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* levelChanges([
|
|
9
|
+
* { ts: new Date('2026-09-30T00:00:00.000Z'), value: 0 },
|
|
10
|
+
* { ts: new Date('2026-09-30T00:00:01.000Z'), value: 0 },
|
|
11
|
+
* { ts: new Date('2026-09-30T00:00:02.000Z'), value: 1 }
|
|
12
|
+
* ]);
|
|
13
|
+
*
|
|
14
|
+
* @param {Array<{ts: Date|string, value: number}>} rows - ordered samples
|
|
15
|
+
* @returns {Array<{ts: Date|string, value: number}>} level changes
|
|
16
|
+
*/
|
|
17
|
+
export default function levelChanges(rows) {
|
|
18
|
+
const kept = [];
|
|
19
|
+
let previous;
|
|
20
|
+
(rows || []).forEach((row) => {
|
|
21
|
+
const value = Number(row.value);
|
|
22
|
+
if (kept.length === 0 || value !== previous) {
|
|
23
|
+
kept.push(row);
|
|
24
|
+
previous = value;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return kept;
|
|
28
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import risingPrefixes from '../risingPrefixes.js';
|
|
2
|
+
import levelChanges from '../levelChanges.js';
|
|
3
|
+
import { seriesWindow, topicRows } from '../topicWindow.js';
|
|
4
|
+
|
|
1
5
|
function bucketKey(tsMs, originMs, stepMs) {
|
|
2
6
|
const slot = Math.floor((tsMs - originMs) / stepMs);
|
|
3
7
|
return originMs + slot * stepMs;
|
|
@@ -23,6 +27,28 @@ function rowsForRange(store, topic, startMs, endMs, stepMs) {
|
|
|
23
27
|
});
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function risesOf(rows, topic, startIso, endIso) {
|
|
31
|
+
const start = new Date(startIso).getTime();
|
|
32
|
+
const end = new Date(endIso).getTime();
|
|
33
|
+
const series = seriesWindow(topicRows(rows, topic), start, end, false);
|
|
34
|
+
return risingPrefixes(series).filter((row) => {
|
|
35
|
+
const ts = new Date(row.ts).getTime();
|
|
36
|
+
return ts >= start && ts < end;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function changesOf(rows, topic, startIso, endIso) {
|
|
41
|
+
const start = new Date(startIso).getTime();
|
|
42
|
+
const end = new Date(endIso).getTime();
|
|
43
|
+
const series = seriesWindow(topicRows(rows, topic), start, end, true);
|
|
44
|
+
return levelChanges(series).map((row, index) => {
|
|
45
|
+
if (index === 0 && new Date(row.ts).getTime() < start) {
|
|
46
|
+
return { ts: new Date(start), value: row.value };
|
|
47
|
+
}
|
|
48
|
+
return { ts: row.ts, value: row.value };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
/**
|
|
27
53
|
* In-memory metrics state port for tests and local runs.
|
|
28
54
|
*
|
|
@@ -45,6 +71,12 @@ export default function metricsStateMemory(store) {
|
|
|
45
71
|
const stepSec = Math.max(1, Math.floor(stepMs / 1000));
|
|
46
72
|
return rowsForRange(store, topic, start, end, stepSec * 1000);
|
|
47
73
|
},
|
|
74
|
+
risesForTopic(topic, startIso, endIso) {
|
|
75
|
+
return risesOf(store.metrics, topic, startIso, endIso);
|
|
76
|
+
},
|
|
77
|
+
changesForTopic(topic, startIso, endIso) {
|
|
78
|
+
return changesOf(store.metrics, topic, startIso, endIso);
|
|
79
|
+
},
|
|
48
80
|
pollTopic(topic, afterIso, untilIso) {
|
|
49
81
|
const after = new Date(afterIso).getTime();
|
|
50
82
|
const until = new Date(untilIso).getTime();
|
|
@@ -9,6 +9,12 @@ export default function metricsStateDisabled() {
|
|
|
9
9
|
rangeForTopic() {
|
|
10
10
|
return Promise.resolve([]);
|
|
11
11
|
},
|
|
12
|
+
risesForTopic() {
|
|
13
|
+
return Promise.resolve([]);
|
|
14
|
+
},
|
|
15
|
+
changesForTopic() {
|
|
16
|
+
return Promise.resolve([]);
|
|
17
|
+
},
|
|
12
18
|
pollTopic() {
|
|
13
19
|
return Promise.resolve([]);
|
|
14
20
|
},
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
return ts instanceof Date ? ts : new Date(ts);
|
|
3
|
-
}
|
|
1
|
+
import seriesRows, { parseTimestamp } from './seriesRows.js';
|
|
4
2
|
|
|
5
3
|
function pollStream(spec) {
|
|
6
4
|
const { read, topic, since, step, callback, clock, unit } = spec;
|
|
@@ -45,6 +43,12 @@ export function pgMetricsRead(metrics) {
|
|
|
45
43
|
range(topic, startIso, endIso, stepMs) {
|
|
46
44
|
return metrics.rangeForTopic(topic, startIso, endIso, stepMs);
|
|
47
45
|
},
|
|
46
|
+
rises(topic, startIso, endIso) {
|
|
47
|
+
return metrics.risesForTopic(topic, startIso, endIso);
|
|
48
|
+
},
|
|
49
|
+
changes(topic, startIso, endIso) {
|
|
50
|
+
return metrics.changesForTopic(topic, startIso, endIso);
|
|
51
|
+
},
|
|
48
52
|
poll(topic, afterIso, untilIso) {
|
|
49
53
|
return metrics.pollTopic(topic, afterIso, untilIso);
|
|
50
54
|
}
|
|
@@ -84,13 +88,23 @@ export default function metricsSensor(read, topic, displayName, unit) {
|
|
|
84
88
|
range.end.toISOString(),
|
|
85
89
|
step
|
|
86
90
|
);
|
|
87
|
-
return rows
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
return seriesRows(rows, unit);
|
|
92
|
+
},
|
|
93
|
+
async rises(range) {
|
|
94
|
+
const rows = await read.rises(
|
|
95
|
+
topic,
|
|
96
|
+
range.start.toISOString(),
|
|
97
|
+
range.end.toISOString()
|
|
98
|
+
);
|
|
99
|
+
return seriesRows(rows, unit);
|
|
100
|
+
},
|
|
101
|
+
async changes(range) {
|
|
102
|
+
const rows = await read.changes(
|
|
103
|
+
topic,
|
|
104
|
+
range.start.toISOString(),
|
|
105
|
+
range.end.toISOString()
|
|
106
|
+
);
|
|
107
|
+
return seriesRows(rows, unit);
|
|
94
108
|
},
|
|
95
109
|
stream(since, step, callback, clock) {
|
|
96
110
|
return pollStream({ read, topic, since, step, callback, clock, unit });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level changes for one Postgres metrics topic, including the held start.
|
|
3
|
+
*
|
|
4
|
+
* $1 topic, $2 start, $3 inclusive end. The sample before start seeds lag.
|
|
5
|
+
*
|
|
6
|
+
* @returns {string} SQL
|
|
7
|
+
*/
|
|
8
|
+
export default function changesSql() {
|
|
9
|
+
return `WITH prior AS (
|
|
10
|
+
SELECT ts, value FROM metrics
|
|
11
|
+
WHERE topic = $1 AND ts < $2
|
|
12
|
+
ORDER BY ts DESC LIMIT 1
|
|
13
|
+
), windowed AS (
|
|
14
|
+
SELECT ts, value FROM metrics
|
|
15
|
+
WHERE topic = $1 AND ts >= $2 AND ts <= $3
|
|
16
|
+
), series AS (
|
|
17
|
+
SELECT ts, value FROM prior
|
|
18
|
+
UNION ALL
|
|
19
|
+
SELECT ts, value FROM windowed
|
|
20
|
+
), marked AS (
|
|
21
|
+
SELECT ts, value,
|
|
22
|
+
LAG(value) OVER (ORDER BY ts) AS prev,
|
|
23
|
+
ROW_NUMBER() OVER (ORDER BY ts) AS n
|
|
24
|
+
FROM series
|
|
25
|
+
)
|
|
26
|
+
SELECT CASE WHEN ts < $2 THEN $2 ELSE ts END AS ts, value
|
|
27
|
+
FROM marked
|
|
28
|
+
WHERE n = 1 OR value IS DISTINCT FROM prev
|
|
29
|
+
ORDER BY ts`;
|
|
30
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import risesSql from './risesSql.js';
|
|
2
|
+
import changesSql from './changesSql.js';
|
|
3
|
+
import queryTopic from './queryTopic.js';
|
|
4
|
+
import rangeSql from './rangeSql.js';
|
|
5
|
+
|
|
1
6
|
export default function metricsStatePg(pool) {
|
|
2
7
|
return {
|
|
3
8
|
async latestForTopic(topic) {
|
|
@@ -7,18 +12,15 @@ export default function metricsStatePg(pool) {
|
|
|
7
12
|
);
|
|
8
13
|
return result.rows[0] ?? null;
|
|
9
14
|
},
|
|
10
|
-
|
|
15
|
+
rangeForTopic(topic, startIso, endIso, stepMs) {
|
|
11
16
|
const seconds = Math.max(1, Math.floor(stepMs / 1000));
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return result.rows.map((row) => {
|
|
20
|
-
return { ts: row.ts, value: row.value };
|
|
21
|
-
});
|
|
17
|
+
return queryTopic(pool, rangeSql(), [`${seconds} seconds`, topic, startIso, endIso]);
|
|
18
|
+
},
|
|
19
|
+
risesForTopic(topic, startIso, endIso) {
|
|
20
|
+
return queryTopic(pool, risesSql(), [topic, startIso, endIso]);
|
|
21
|
+
},
|
|
22
|
+
changesForTopic(topic, startIso, endIso) {
|
|
23
|
+
return queryTopic(pool, changesSql(), [topic, startIso, endIso]);
|
|
22
24
|
},
|
|
23
25
|
async pollTopic(topic, afterIso, untilIso) {
|
|
24
26
|
const result = await pool.query(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a topic SQL and maps ts/value rows.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} pool - Postgres pool
|
|
5
|
+
* @param {string} sql - query text
|
|
6
|
+
* @param {Array} params - query params
|
|
7
|
+
* @returns {Promise<Array<{ts: Date|string, value: number}>>} rows
|
|
8
|
+
*/
|
|
9
|
+
export default async function queryTopic(pool, sql, params) {
|
|
10
|
+
const result = await pool.query(sql, params);
|
|
11
|
+
return result.rows.map((row) => {
|
|
12
|
+
return { ts: row.ts, value: Number(row.value) };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downsampled Postgres range for one metrics topic.
|
|
3
|
+
*
|
|
4
|
+
* $1 interval, $2 topic, $3 start, $4 end.
|
|
5
|
+
*
|
|
6
|
+
* @returns {string} SQL
|
|
7
|
+
*/
|
|
8
|
+
export default function rangeSql() {
|
|
9
|
+
return `SELECT bucket AS ts, value FROM (
|
|
10
|
+
SELECT date_bin($1::interval, ts, '1970-01-01'::timestamptz) AS bucket, value,
|
|
11
|
+
ROW_NUMBER() OVER (PARTITION BY date_bin($1::interval, ts, '1970-01-01'::timestamptz) ORDER BY ts DESC) AS rn
|
|
12
|
+
FROM metrics WHERE topic = $2 AND ts >= $3 AND ts <= $4
|
|
13
|
+
) sub WHERE rn = 1 ORDER BY ts`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix sum of 0→1 edges for one Postgres metrics topic.
|
|
3
|
+
*
|
|
4
|
+
* $1 topic, $2 start, $3 exclusive end. The sample before start seeds lag.
|
|
5
|
+
*
|
|
6
|
+
* @returns {string} SQL
|
|
7
|
+
*/
|
|
8
|
+
export default function risesSql() {
|
|
9
|
+
return `WITH prior AS (
|
|
10
|
+
SELECT ts, value FROM metrics
|
|
11
|
+
WHERE topic = $1 AND ts < $2
|
|
12
|
+
ORDER BY ts DESC LIMIT 1
|
|
13
|
+
), windowed AS (
|
|
14
|
+
SELECT ts, value FROM metrics
|
|
15
|
+
WHERE topic = $1 AND ts >= $2 AND ts < $3
|
|
16
|
+
), series AS (
|
|
17
|
+
SELECT ts, value FROM prior
|
|
18
|
+
UNION ALL
|
|
19
|
+
SELECT ts, value FROM windowed
|
|
20
|
+
), marked AS (
|
|
21
|
+
SELECT ts, value, LAG(value) OVER (ORDER BY ts) AS prev
|
|
22
|
+
FROM series
|
|
23
|
+
)
|
|
24
|
+
SELECT marked.ts AS ts,
|
|
25
|
+
SUM(1) OVER (ORDER BY marked.ts) AS value
|
|
26
|
+
FROM marked
|
|
27
|
+
WHERE marked.ts >= $2 AND marked.prev = 0 AND marked.value = 1
|
|
28
|
+
ORDER BY marked.ts`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a prefix sum of 0→1 edges on an ordered numeric series.
|
|
3
|
+
*
|
|
4
|
+
* Heartbeat repeats of 1 do not increment. A series that starts already
|
|
5
|
+
* high does not produce a prefix until a later 0→1 pair.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* risingPrefixes([
|
|
9
|
+
* { ts: new Date('2026-09-30T00:00:00.000Z'), value: 0 },
|
|
10
|
+
* { ts: new Date('2026-09-30T00:00:01.000Z'), value: 1 }
|
|
11
|
+
* ]);
|
|
12
|
+
*
|
|
13
|
+
* @param {Array<{ts: Date|string, value: number}>} rows - ordered samples
|
|
14
|
+
* @returns {Array<{ts: Date|string, value: number}>} prefix points
|
|
15
|
+
*/
|
|
16
|
+
export default function risingPrefixes(rows) {
|
|
17
|
+
let previous;
|
|
18
|
+
let count = 0;
|
|
19
|
+
const prefixes = [];
|
|
20
|
+
(rows || []).forEach((row) => {
|
|
21
|
+
const value = Number(row.value);
|
|
22
|
+
if (previous === 0 && value === 1) {
|
|
23
|
+
count += 1;
|
|
24
|
+
prefixes.push({ ts: row.ts, value: count });
|
|
25
|
+
}
|
|
26
|
+
previous = value;
|
|
27
|
+
});
|
|
28
|
+
return prefixes;
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function parseTimestamp(ts) {
|
|
2
|
+
return ts instanceof Date ? ts : new Date(ts);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maps storage rows onto the sensor measurement shape.
|
|
7
|
+
*
|
|
8
|
+
* @param {Array<{ts: Date|string, value: number}>} rows - storage rows
|
|
9
|
+
* @param {string} unit - measurement unit
|
|
10
|
+
* @returns {Array<{timestamp: Date, value: number, unit: string}>} series
|
|
11
|
+
*/
|
|
12
|
+
export default function seriesRows(rows, unit) {
|
|
13
|
+
return rows.map((row) => {
|
|
14
|
+
return {
|
|
15
|
+
timestamp: parseTimestamp(row.ts),
|
|
16
|
+
value: row.value,
|
|
17
|
+
unit
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { parseTimestamp };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orders one topic and splits a prior sample from the requested window.
|
|
3
|
+
*
|
|
4
|
+
* @param {Array<{topic: string, ts: Date|string, value: number}>} rows - store
|
|
5
|
+
* @param {string} topic - metric topic
|
|
6
|
+
* @returns {Array<{topic: string, ts: Date, value: number}>} time-ordered hits
|
|
7
|
+
*/
|
|
8
|
+
export function topicRows(rows, topic) {
|
|
9
|
+
return (rows || []).filter((row) => {
|
|
10
|
+
return row.topic === topic;
|
|
11
|
+
}).map((row) => {
|
|
12
|
+
return { topic: row.topic, ts: new Date(row.ts), value: row.value };
|
|
13
|
+
}).sort((left, right) => {
|
|
14
|
+
return left.ts.getTime() - right.ts.getTime();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Preceding sample plus in-window rows for lag-based queries.
|
|
20
|
+
*
|
|
21
|
+
* @param {Array<{ts: Date, value: number}>} rows - one topic ordered
|
|
22
|
+
* @param {number} startMs - window start
|
|
23
|
+
* @param {number} endMs - window end
|
|
24
|
+
* @param {boolean} closed - true when the end is inclusive
|
|
25
|
+
* @returns {Array<{ts: Date, value: number}>} prior plus window
|
|
26
|
+
*/
|
|
27
|
+
export function seriesWindow(rows, startMs, endMs, closed) {
|
|
28
|
+
const prior = rows.filter((row) => {
|
|
29
|
+
return row.ts.getTime() < startMs;
|
|
30
|
+
}).pop();
|
|
31
|
+
const window = rows.filter((row) => {
|
|
32
|
+
const ts = row.ts.getTime();
|
|
33
|
+
if (ts < startMs) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return closed ? ts <= endMs : ts < endMs;
|
|
37
|
+
});
|
|
38
|
+
return prior ? [prior].concat(window) : window;
|
|
39
|
+
}
|