@yarkivaev/scada 2.3.85 → 2.3.87
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 +20 -0
- package/index.js +5 -0
- package/package.json +4 -2
- package/src/application/mcp/analyticsMcp.js +376 -0
- package/src/application/mcp/coverageCursor.js +116 -0
- package/src/application/mcp/mcpCoverage.js +95 -0
- package/src/application/mcp/mcpJournal.js +102 -0
- package/src/application/mcp/mcpMetrics.js +69 -0
- package/src/application/mcp/mcpObserve.js +168 -0
- package/src/application/mcp/mcpQuery.js +80 -0
- package/src/application/mcp/mcpReads.js +221 -0
- package/src/application/mcp/mcpResources.js +106 -0
- package/src/application/mcp/mcpText.js +106 -0
- package/src/application/metricsPlant.js +19 -1
- package/src/application/shopWithTimeline.js +15 -6
- package/src/domain/namespace/namespaceTree.js +182 -0
- package/src/domain/plant/plant.js +2 -1
- package/src/domain/span/coverageSpans.js +229 -0
- package/src/domain/sql/sqlGuard.js +175 -0
- package/src/domain/sql/sqlShape.js +16 -0
- package/src/infrastructure/persistence/pg/alerts.js +37 -3
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads epoch milliseconds from a number, Date, or parseable string.
|
|
3
|
+
*
|
|
4
|
+
* @param {Date|number|string} value - instant
|
|
5
|
+
* @returns {number} epoch milliseconds, or NaN
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* epoch('2024-11-02T03:04:05.137Z');
|
|
9
|
+
*/
|
|
10
|
+
function epoch(value) {
|
|
11
|
+
if (typeof value === 'number') {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (value instanceof Date) {
|
|
15
|
+
return value.getTime();
|
|
16
|
+
}
|
|
17
|
+
return Date.parse(String(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Converts an instant and rejects values that are not a time.
|
|
22
|
+
*
|
|
23
|
+
* @param {Date|number|string} value - instant
|
|
24
|
+
* @returns {number} epoch milliseconds
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* stamp('2024-11-02T03:04:05.137Z');
|
|
28
|
+
*/
|
|
29
|
+
function stamp(value) {
|
|
30
|
+
const at = epoch(value);
|
|
31
|
+
if (Number.isNaN(at)) {
|
|
32
|
+
throw new Error(`Coverage instant is not a time: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
return at;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads a sample instant from a raw value or a ts field.
|
|
39
|
+
*
|
|
40
|
+
* @param {Date|number|string|object} sample - sample
|
|
41
|
+
* @returns {Date|number|string} instant
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* readSample({ ts: '2024-11-02T03:04:05.137Z' });
|
|
45
|
+
*/
|
|
46
|
+
function readSample(sample) {
|
|
47
|
+
if (sample && typeof sample === 'object' && Object.hasOwn(sample, 'ts')) {
|
|
48
|
+
return sample.ts;
|
|
49
|
+
}
|
|
50
|
+
return sample;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Keeps samples inside the cursor-covered part of the window, in order.
|
|
55
|
+
*
|
|
56
|
+
* @param {Array} samples - raw samples
|
|
57
|
+
* @param {number} start - window start epoch
|
|
58
|
+
* @param {number} stop - covered end epoch
|
|
59
|
+
* @returns {Array<number>} sorted epochs
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* clip(['2024-11-02T03:04:05.137Z'], 0, 1);
|
|
63
|
+
*/
|
|
64
|
+
function clip(samples, start, stop) {
|
|
65
|
+
const picked = [];
|
|
66
|
+
for (const sample of samples) {
|
|
67
|
+
const at = stamp(readSample(sample));
|
|
68
|
+
if (at >= start && at <= stop) {
|
|
69
|
+
picked.push(at);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
picked.sort((left, right) => {
|
|
73
|
+
return left - right;
|
|
74
|
+
});
|
|
75
|
+
return picked;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds one span per consecutive bound.
|
|
80
|
+
*
|
|
81
|
+
* @param {number} start - covered start
|
|
82
|
+
* @param {number} stop - covered end
|
|
83
|
+
* @param {Array<number>} points - sample epochs
|
|
84
|
+
* @param {number} silence - silence milliseconds
|
|
85
|
+
* @returns {Array<object>} unmerged spans
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* gaps(0, 10, [0, 10], 30);
|
|
89
|
+
*/
|
|
90
|
+
function gaps(start, stop, points, silence) {
|
|
91
|
+
const bounds = [start, ...points, stop];
|
|
92
|
+
const spans = [];
|
|
93
|
+
for (let index = 0; index < bounds.length - 1; index += 1) {
|
|
94
|
+
const left = bounds[index];
|
|
95
|
+
const right = bounds[index + 1];
|
|
96
|
+
const status = right - left > silence ? 'absent' : 'present';
|
|
97
|
+
spans.push({ from: left, status, to: right });
|
|
98
|
+
}
|
|
99
|
+
return spans;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drops empty spans and joins neighbors that share a status.
|
|
104
|
+
*
|
|
105
|
+
* @param {Array<object>} spans - raw spans
|
|
106
|
+
* @returns {Array<object>} merged spans
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* merge([{ from: 0, status: 'present', to: 10 }]);
|
|
110
|
+
*/
|
|
111
|
+
function merge(spans) {
|
|
112
|
+
const open = spans.filter((span) => {
|
|
113
|
+
return span.to > span.from;
|
|
114
|
+
});
|
|
115
|
+
const kept = [];
|
|
116
|
+
for (const span of open) {
|
|
117
|
+
const last = kept[kept.length - 1];
|
|
118
|
+
if (last && last.status === span.status && last.to === span.from) {
|
|
119
|
+
kept[kept.length - 1] = { from: last.from, status: last.status, to: span.to };
|
|
120
|
+
} else {
|
|
121
|
+
kept.push({ from: span.from, status: span.status, to: span.to });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return kept;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Classifies the cursor-covered interval.
|
|
129
|
+
*
|
|
130
|
+
* @param {number} start - window start
|
|
131
|
+
* @param {number} stop - covered end
|
|
132
|
+
* @param {Array<number>} points - samples inside the covered interval
|
|
133
|
+
* @param {number} silence - silence milliseconds
|
|
134
|
+
* @returns {Array<object>} present and absent spans
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* paint(0, 10, [], 30);
|
|
138
|
+
*/
|
|
139
|
+
function paint(start, stop, points, silence) {
|
|
140
|
+
if (stop <= start) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
if (points.length === 0) {
|
|
144
|
+
return [{ from: start, status: 'absent', to: stop }];
|
|
145
|
+
}
|
|
146
|
+
return merge(gaps(start, stop, points, silence));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Renders spans as ISO-8601 instants.
|
|
151
|
+
*
|
|
152
|
+
* @param {Array<object>} spans - epoch spans
|
|
153
|
+
* @returns {Array<object>} ISO spans
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* iso([{ from: 0, status: 'unknown', to: 1 }]);
|
|
157
|
+
*/
|
|
158
|
+
function iso(spans) {
|
|
159
|
+
return spans.map((span) => {
|
|
160
|
+
return {
|
|
161
|
+
from: new Date(span.from).toISOString(),
|
|
162
|
+
status: span.status,
|
|
163
|
+
to: new Date(span.to).toISOString()
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Requires a non-negative silence gap.
|
|
170
|
+
*
|
|
171
|
+
* @param {number} silenceMs - silence milliseconds
|
|
172
|
+
* @returns {number} silence milliseconds
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* silenceOf(60000);
|
|
176
|
+
*/
|
|
177
|
+
function silenceOf(silenceMs) {
|
|
178
|
+
if (typeof silenceMs === 'number' && silenceMs >= 0) {
|
|
179
|
+
return silenceMs;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`Coverage silence is missing: ${silenceMs}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Requires an array of samples.
|
|
186
|
+
*
|
|
187
|
+
* @param {Array} samples - raw samples
|
|
188
|
+
* @returns {Array} samples
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* samplesOf([]);
|
|
192
|
+
*/
|
|
193
|
+
function samplesOf(samples) {
|
|
194
|
+
if (Array.isArray(samples)) {
|
|
195
|
+
return samples;
|
|
196
|
+
}
|
|
197
|
+
throw new Error('Coverage samples are missing');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Classifies a metric window as present, absent, or unknown.
|
|
202
|
+
*
|
|
203
|
+
* Present means consecutive samples stay within the silence gap. Absent means
|
|
204
|
+
* a hole is longer than that gap and the ingest cursor has already passed it.
|
|
205
|
+
* Unknown is the part of the window after the cursor. An empty window that the
|
|
206
|
+
* cursor already covered is absent.
|
|
207
|
+
*
|
|
208
|
+
* @param {Array} samples - instants, or objects with ts
|
|
209
|
+
* @param {Date|number|string} from - window start
|
|
210
|
+
* @param {Date|number|string} to - window end
|
|
211
|
+
* @param {number} silenceMs - maximum present gap in milliseconds
|
|
212
|
+
* @param {Date|number|string} cursor - latest ingested instant
|
|
213
|
+
* @returns {Array<{from: string, to: string, status: string}>} ISO spans
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* coverageSpans(['2024-11-02T03:04:05.137Z'], '2024-11-02T03:04:05.137Z',
|
|
217
|
+
* '2024-11-02T03:05:05.137Z', 60000, '2024-11-02T03:05:05.137Z');
|
|
218
|
+
*/
|
|
219
|
+
export default function coverageSpans(samples, from, to, silenceMs, cursor) {
|
|
220
|
+
const end = stamp(to);
|
|
221
|
+
const mark = stamp(cursor);
|
|
222
|
+
const silence = silenceOf(silenceMs);
|
|
223
|
+
const start = stamp(from);
|
|
224
|
+
const stop = mark < end ? Math.max(mark, start) : end;
|
|
225
|
+
const points = clip(samplesOf(samples), start, stop);
|
|
226
|
+
const known = paint(start, stop, points, silence);
|
|
227
|
+
const tail = stop < end ? [{ from: stop, status: 'unknown', to: end }] : [];
|
|
228
|
+
return iso(merge(known.concat(tail)));
|
|
229
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requires SQL text.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} sql - statement
|
|
5
|
+
* @param {string} engine - clickhouse or postgres
|
|
6
|
+
* @returns {string} sql
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* textOf('SELECT 1', 'postgres');
|
|
10
|
+
*/
|
|
11
|
+
function textOf(sql, engine) {
|
|
12
|
+
if (typeof sql === 'string') {
|
|
13
|
+
return sql;
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`SQL text is missing for ${engine}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strips comments and masks string literals so keyword checks see code.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} sql - raw statement
|
|
22
|
+
* @returns {string} masked statement
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* mask("SELECT 'drop'");
|
|
26
|
+
*/
|
|
27
|
+
function mask(sql) {
|
|
28
|
+
const comments = sql.replace(/\/\*[\s\S]*?\*\//gu, ' ');
|
|
29
|
+
const lines = comments.replace(/--[^\n]*/gu, ' ');
|
|
30
|
+
return lines.replace(/'(?:''|[^'])*'/gu, '?');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Keeps a single statement and rejects empties and batches.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} masked - masked SQL
|
|
37
|
+
* @param {string} engine - engine name
|
|
38
|
+
* @returns {string} trimmed statement
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* one('SELECT 1', 'postgres');
|
|
42
|
+
*/
|
|
43
|
+
function one(masked, engine) {
|
|
44
|
+
const trimmed = masked.trim().replace(/;+\s*$/u, '').trim();
|
|
45
|
+
if (trimmed.includes(';')) {
|
|
46
|
+
throw new Error(`SQL has more than one statement for ${engine}`);
|
|
47
|
+
}
|
|
48
|
+
if (trimmed.length === 0) {
|
|
49
|
+
throw new Error(`SQL is empty for ${engine}`);
|
|
50
|
+
}
|
|
51
|
+
return trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Shortens SQL for an error without adding a sentence period.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} body - statement
|
|
58
|
+
* @returns {string} single-line preview
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* preview('SELECT 1');
|
|
62
|
+
*/
|
|
63
|
+
function preview(body) {
|
|
64
|
+
const flat = body.replace(/\s+/gu, ' ').trim();
|
|
65
|
+
if (flat.length > 120) {
|
|
66
|
+
return flat.slice(0, 120);
|
|
67
|
+
}
|
|
68
|
+
return flat;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Allows only SELECT and WITH.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} body - one statement
|
|
75
|
+
* @param {string} engine - engine name
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* assertRead('SELECT 1', 'postgres');
|
|
80
|
+
*/
|
|
81
|
+
function assertRead(body, engine) {
|
|
82
|
+
if (/^(?:select|with)\b/iu.test(body)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`SQL is not a read for ${engine}: ${preview(body)}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rejects write and admin keywords.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} body - masked statement
|
|
92
|
+
* @param {string} engine - engine name
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* assertWords('SELECT 1', 'postgres');
|
|
97
|
+
*/
|
|
98
|
+
function assertWords(body, engine) {
|
|
99
|
+
const found = /\b(?<word>alter|copy|delete|drop|grant|insert|system|truncate|update)\b/iu.exec(body);
|
|
100
|
+
if (found) {
|
|
101
|
+
throw new Error(`SQL contains ${found.groups.word.toLowerCase()} for ${engine}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Rejects the operator credential column.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} body - masked statement
|
|
109
|
+
* @param {string} engine - engine name
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* assertUid('SELECT name FROM operators', 'postgres');
|
|
114
|
+
*/
|
|
115
|
+
function assertUid(body, engine) {
|
|
116
|
+
if (/\bcard_uid\b/iu.test(body)) {
|
|
117
|
+
throw new Error(`SQL references card_uid for ${engine}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Requires a ts comparison on ClickHouse.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} body - masked statement
|
|
125
|
+
* @param {string} engine - engine name
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* assertTs('SELECT value FROM metrics WHERE ts >= 1', 'clickhouse');
|
|
130
|
+
*/
|
|
131
|
+
function assertTs(body, engine) {
|
|
132
|
+
if (engine !== 'clickhouse') {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (/\bts\s*(?:>=|<=|<>|!=|>|<|=|between\b|in\b)/iu.test(body)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`ClickHouse SQL lacks a ts predicate: ${preview(body)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Trims one trailing semicolon from the original text.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} sql - original statement
|
|
145
|
+
* @returns {string} statement to execute
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* executable('SELECT 1;');
|
|
149
|
+
*/
|
|
150
|
+
function executable(sql) {
|
|
151
|
+
return sql.trim().replace(/;+\s*$/u, '').trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rejects analytics SQL that is not one read-only statement.
|
|
156
|
+
*
|
|
157
|
+
* SELECT and WITH are allowed. Writes, card_uid, and extra statements are
|
|
158
|
+
* rejected before a driver sees them. ClickHouse also needs a ts predicate.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} sql - one statement
|
|
161
|
+
* @param {string} engine - clickhouse or postgres
|
|
162
|
+
* @returns {string} trimmed statement safe to execute
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* sqlGuard("SELECT value FROM metrics WHERE ts >= '2024-11-02'", 'clickhouse');
|
|
166
|
+
*/
|
|
167
|
+
export default function sqlGuard(sql, engine) {
|
|
168
|
+
const text = textOf(sql, engine);
|
|
169
|
+
const body = one(mask(text), engine);
|
|
170
|
+
assertRead(body, engine);
|
|
171
|
+
assertWords(body, engine);
|
|
172
|
+
assertUid(body, engine);
|
|
173
|
+
assertTs(body, engine);
|
|
174
|
+
return executable(text);
|
|
175
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapses SQL literals so journals group by statement shape.
|
|
3
|
+
*
|
|
4
|
+
* Quoted strings and numbers become ?. Whitespace becomes a single space.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} sql - original statement
|
|
7
|
+
* @returns {string} shape
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* sqlShape("SELECT value FROM metrics WHERE topic = 'α' AND ts >= 1");
|
|
11
|
+
*/
|
|
12
|
+
export default function sqlShape(sql) {
|
|
13
|
+
const quotes = String(sql).replace(/'(?:''|[^'])*'/gu, '?');
|
|
14
|
+
const numbers = quotes.replace(/\b\d+(?:\.\d+)?\b/gu, '?');
|
|
15
|
+
return numbers.replace(/\s+/gu, ' ').trim();
|
|
16
|
+
}
|
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Builds a filtered alerts SELECT.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} filters - optional machine, from, to
|
|
5
|
+
* @returns {{sql: string, prm: Array}} query
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* listSql({ machine: 'm1', from: start, to: end });
|
|
9
|
+
*/
|
|
10
|
+
function listSql(filters) {
|
|
11
|
+
const prm = [];
|
|
12
|
+
const clauses = [];
|
|
13
|
+
const scope = filters || {};
|
|
14
|
+
if (scope.machine) {
|
|
15
|
+
prm.push(scope.machine);
|
|
16
|
+
clauses.push(`machine = $${prm.length}`);
|
|
17
|
+
}
|
|
18
|
+
if (scope.from) {
|
|
19
|
+
prm.push(scope.from);
|
|
20
|
+
clauses.push(`"timestamp" >= $${prm.length}`);
|
|
21
|
+
}
|
|
22
|
+
if (scope.to) {
|
|
23
|
+
prm.push(scope.to);
|
|
24
|
+
clauses.push(`"timestamp" <= $${prm.length}`);
|
|
25
|
+
}
|
|
26
|
+
const where = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`;
|
|
27
|
+
return { prm, sql: `SELECT * FROM alerts${where} ORDER BY timestamp` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PostgreSQL alert hydration and range-read port.
|
|
3
32
|
*
|
|
4
33
|
* @param {object} pool - pg pool
|
|
5
|
-
* @returns {object} alerts port with put and listUnacknowledged
|
|
34
|
+
* @returns {object} alerts port with put, list, and listUnacknowledged
|
|
6
35
|
*
|
|
7
36
|
* @example
|
|
8
37
|
* const store = alertsStatePg(pool);
|
|
9
|
-
* const rows = await store.
|
|
38
|
+
* const rows = await store.list({ machine: 'm1', from, to });
|
|
10
39
|
*/
|
|
11
40
|
export default function alertsStatePg(pool) {
|
|
12
41
|
return {
|
|
@@ -25,6 +54,11 @@ export default function alertsStatePg(pool) {
|
|
|
25
54
|
);
|
|
26
55
|
return inserted.rows[0];
|
|
27
56
|
},
|
|
57
|
+
async list(filters) {
|
|
58
|
+
const query = listSql(filters);
|
|
59
|
+
const result = await pool.query(query.sql, query.prm);
|
|
60
|
+
return result.rows;
|
|
61
|
+
},
|
|
28
62
|
async listUnacknowledged(filters) {
|
|
29
63
|
let sql = 'SELECT * FROM alerts WHERE acknowledged = FALSE';
|
|
30
64
|
const prm = [];
|