@mxtommy/kip 4.7.0-beta.2 → 4.7.0-beta.4
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/plugin/history-series.service.js +91 -13
- package/plugin/index.js +110 -8
- package/plugin/kip-plugin/src/history-series.service.js +537 -0
- package/plugin/kip-plugin/src/index.js +1194 -0
- package/plugin/kip-plugin/src/openApi.json +787 -0
- package/plugin/kip-plugin/src/sqlite-history-storage.service.js +1122 -0
- package/plugin/openApi.json +14 -0
- package/plugin/sqlite-history-storage.service.js +13 -19
- package/plugin/src/app/core/contracts/kip-series-contract.js +14 -0
- package/plugin/src/app/core/contracts/kip-series-contract.spec.js +68 -0
- package/public/assets/svg/icons.svg +1 -1
- package/public/{chunk-3MSOVKX6.js → chunk-4YDVZHMH.js} +1 -1
- package/public/{chunk-NK7SNP45.js → chunk-AQROQY2F.js} +1 -1
- package/public/{chunk-CMHH7BXX.js → chunk-BQPPRM7O.js} +1 -1
- package/public/{chunk-FVGLVFWP.js → chunk-ETXVA6Z3.js} +1 -1
- package/public/{chunk-64MHGMIL.js → chunk-GYJAOUYL.js} +2 -2
- package/public/{chunk-Y4DXERRE.js → chunk-H2I7BMAK.js} +12 -12
- package/public/{chunk-7EAIOLCB.js → chunk-IENESD5Q.js} +1 -1
- package/public/{chunk-NFUW7ILE.js → chunk-LWHL6XXC.js} +1 -1
- package/public/{chunk-CHMMSVYD.js → chunk-M37BLWHF.js} +5 -5
- package/public/{chunk-7H5VXIPS.js → chunk-NOZ6S7A5.js} +1 -1
- package/public/{chunk-EZZ4IJBX.js → chunk-TABZLTIT.js} +1 -1
- package/public/{chunk-B3VMWHNV.js → chunk-YY4ZUJFI.js} +1 -1
- package/public/index.html +1 -1
- package/public/{main-VB3XIM4H.js → main-IECWDNBW.js} +1 -1
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HistorySeriesService = exports.isKipTemplateSeriesDefinition = exports.isKipSeriesEnabled = exports.isKipConcreteSeriesDefinition = void 0;
|
|
4
|
+
const kip_series_contract_1 = require("../../src/app/core/contracts/kip-series-contract");
|
|
5
|
+
Object.defineProperty(exports, "isKipConcreteSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipConcreteSeriesDefinition; } });
|
|
6
|
+
Object.defineProperty(exports, "isKipSeriesEnabled", { enumerable: true, get: function () { return kip_series_contract_1.isKipSeriesEnabled; } });
|
|
7
|
+
Object.defineProperty(exports, "isKipTemplateSeriesDefinition", { enumerable: true, get: function () { return kip_series_contract_1.isKipTemplateSeriesDefinition; } });
|
|
8
|
+
/**
|
|
9
|
+
* Manages history capture series definitions and serves History API-compatible query results.
|
|
10
|
+
*/
|
|
11
|
+
class HistorySeriesService {
|
|
12
|
+
nowProvider;
|
|
13
|
+
selfContext;
|
|
14
|
+
seriesById = new Map();
|
|
15
|
+
enabledSeriesKeysByPath = new Map();
|
|
16
|
+
lastAcceptedTimestampBySeriesKey = new Map();
|
|
17
|
+
sampleSink = null;
|
|
18
|
+
constructor(nowProvider = () => Date.now(), selfContext = null) {
|
|
19
|
+
this.nowProvider = nowProvider;
|
|
20
|
+
this.selfContext = selfContext;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Returns all configured series sorted by `seriesId`.
|
|
24
|
+
*
|
|
25
|
+
* @returns Ordered list of series definitions.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const list = service.listSeries();
|
|
29
|
+
* console.log(list.length);
|
|
30
|
+
*/
|
|
31
|
+
listSeries() {
|
|
32
|
+
return Array.from(this.seriesById.values()).sort((left, right) => {
|
|
33
|
+
return left.seriesId.localeCompare(right.seriesId);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Finds a series by identifier.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} seriesId Series identifier.
|
|
40
|
+
* @returns {ISeriesDefinition | null} Matching series or null.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const row = service.findSeriesById('series-1');
|
|
44
|
+
*/
|
|
45
|
+
findSeriesById(seriesId) {
|
|
46
|
+
return this.seriesById.get(seriesId) ?? null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Creates or updates a series definition.
|
|
50
|
+
*
|
|
51
|
+
* @param {ISeriesDefinition} input The incoming series definition payload.
|
|
52
|
+
* @returns {ISeriesDefinition} The normalized and stored series definition.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* service.upsertSeries({
|
|
56
|
+
* seriesId: 'abc',
|
|
57
|
+
* datasetUuid: 'abc',
|
|
58
|
+
* ownerWidgetUuid: 'widget-1',
|
|
59
|
+
* path: 'navigation.speedOverGround'
|
|
60
|
+
* });
|
|
61
|
+
*/
|
|
62
|
+
upsertSeries(input) {
|
|
63
|
+
const normalized = this.normalizeSeries(input);
|
|
64
|
+
const key = normalized.seriesId;
|
|
65
|
+
this.seriesById.set(key, normalized);
|
|
66
|
+
this.rebuildEnabledPathIndex();
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registers a callback invoked for every accepted sample.
|
|
71
|
+
*
|
|
72
|
+
* @param {(sample: IRecordedSeriesSample) => void | null} sink Callback used to forward samples to persistent storage.
|
|
73
|
+
* @returns {void}
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* service.setSampleSink(sample => console.log(sample.seriesId));
|
|
77
|
+
*/
|
|
78
|
+
setSampleSink(sink) {
|
|
79
|
+
this.sampleSink = sink;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Deletes an existing series definition and all captured samples for the series.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} seriesId Unique series identifier.
|
|
85
|
+
* @returns {boolean} True when a series existed and was deleted.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* const deleted = service.deleteSeries('abc');
|
|
89
|
+
*/
|
|
90
|
+
deleteSeries(seriesId) {
|
|
91
|
+
const keysToDelete = Array.from(this.seriesById.entries())
|
|
92
|
+
.filter(([, series]) => series.seriesId === seriesId)
|
|
93
|
+
.map(([key]) => key);
|
|
94
|
+
if (keysToDelete.length === 0) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
keysToDelete.forEach(key => {
|
|
98
|
+
this.seriesById.delete(key);
|
|
99
|
+
this.lastAcceptedTimestampBySeriesKey.delete(key);
|
|
100
|
+
});
|
|
101
|
+
this.rebuildEnabledPathIndex();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reconciles the entire desired series set against the current state.
|
|
106
|
+
*
|
|
107
|
+
* @param {ISeriesDefinition[]} desiredSeries Full desired series payload list.
|
|
108
|
+
* @returns {{ created: number; updated: number; deleted: number; total: number }} Reconciliation summary.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const result = service.reconcileSeries([{ seriesId: 's1', datasetUuid: 's1', ownerWidgetUuid: 'w1', path: 'p' }]);
|
|
112
|
+
* console.log(result.created, result.deleted);
|
|
113
|
+
*/
|
|
114
|
+
reconcileSeries(desiredSeries) {
|
|
115
|
+
const now = this.nowProvider();
|
|
116
|
+
const desiredById = new Map();
|
|
117
|
+
desiredSeries.forEach(entry => {
|
|
118
|
+
const normalized = this.normalizeSeries(entry);
|
|
119
|
+
const key = normalized.seriesId;
|
|
120
|
+
desiredById.set(key, normalized);
|
|
121
|
+
});
|
|
122
|
+
let created = 0;
|
|
123
|
+
let updated = 0;
|
|
124
|
+
let deleted = 0;
|
|
125
|
+
desiredById.forEach((desired, seriesKey) => {
|
|
126
|
+
const existing = this.seriesById.get(seriesKey);
|
|
127
|
+
// Always update reconcile_ts on reconcile
|
|
128
|
+
const desiredWithReconcile = { ...desired, reconcileTs: now };
|
|
129
|
+
if (!existing) {
|
|
130
|
+
this.seriesById.set(seriesKey, desiredWithReconcile);
|
|
131
|
+
created += 1;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!this.areSeriesEquivalent(existing, desired)) {
|
|
135
|
+
this.seriesById.set(seriesKey, desiredWithReconcile);
|
|
136
|
+
updated += 1;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Even if not updated, update reconcileTs
|
|
140
|
+
this.seriesById.set(seriesKey, { ...existing, reconcileTs: now });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
Array.from(this.seriesById.keys()).forEach(seriesKey => {
|
|
144
|
+
if (!desiredById.has(seriesKey)) {
|
|
145
|
+
this.seriesById.delete(seriesKey);
|
|
146
|
+
this.lastAcceptedTimestampBySeriesKey.delete(seriesKey);
|
|
147
|
+
deleted += 1;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
this.rebuildEnabledPathIndex();
|
|
151
|
+
return {
|
|
152
|
+
created,
|
|
153
|
+
updated,
|
|
154
|
+
deleted,
|
|
155
|
+
total: this.seriesById.size
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Records a single numeric sample for a configured series.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} seriesId Unique series identifier.
|
|
162
|
+
* @param {number} value Numeric sample value.
|
|
163
|
+
* @param {number} timestamp Sample timestamp in milliseconds.
|
|
164
|
+
* @returns {boolean} True when sample was accepted.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* service.recordSample('abc', 12.4, Date.now());
|
|
168
|
+
*/
|
|
169
|
+
recordSample(seriesId, value, timestamp) {
|
|
170
|
+
if (!this.seriesById.has(seriesId)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return this.recordSampleByKey(seriesId, value, timestamp);
|
|
174
|
+
}
|
|
175
|
+
recordSampleByKey(seriesKey, value, timestamp) {
|
|
176
|
+
const series = this.seriesById.get(seriesKey);
|
|
177
|
+
if (!series || series.enabled === false || !Number.isFinite(value) || !Number.isFinite(timestamp)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const previousTimestamp = this.lastAcceptedTimestampBySeriesKey.get(seriesKey);
|
|
181
|
+
// Enforces a minimum of 1 second to prevent excessive sampling on short retention durations
|
|
182
|
+
const minSampleTime = Math.max(Number(series.sampleTime) || 0, 1000);
|
|
183
|
+
if (previousTimestamp !== undefined && (timestamp - previousTimestamp) < minSampleTime) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const context = series.context ?? 'vessels.self';
|
|
187
|
+
const source = series.source ?? 'default';
|
|
188
|
+
this.sampleSink?.({
|
|
189
|
+
seriesId: series.seriesId,
|
|
190
|
+
datasetUuid: series.datasetUuid,
|
|
191
|
+
ownerWidgetUuid: series.ownerWidgetUuid,
|
|
192
|
+
path: series.path,
|
|
193
|
+
context,
|
|
194
|
+
source,
|
|
195
|
+
timestamp,
|
|
196
|
+
value
|
|
197
|
+
});
|
|
198
|
+
this.lastAcceptedTimestampBySeriesKey.set(seriesKey, timestamp);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Records a sample based on a Signal K stream value by matching path/context/source against configured series.
|
|
203
|
+
*
|
|
204
|
+
* @param {{ path?: unknown; value?: unknown; timestamp?: unknown; context?: unknown; source?: unknown; $source?: unknown }} sample Signal K normalized value entry.
|
|
205
|
+
* @returns {number} Number of configured series that accepted the sample.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* const count = service.recordFromSignalKSample({ path: 'navigation.speedOverGround', value: 6.2, timestamp: new Date().toISOString() });
|
|
209
|
+
*/
|
|
210
|
+
recordFromSignalKSample(sample) {
|
|
211
|
+
const path = this.normalizePathIdentifier(typeof sample.path === 'string' ? sample.path : '');
|
|
212
|
+
if (!path) {
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
const ts = this.resolveTimestamp(sample.timestamp);
|
|
216
|
+
const context = typeof sample.context === 'string' && sample.context ? sample.context : 'vessels.self';
|
|
217
|
+
const source = this.resolveSource(sample);
|
|
218
|
+
const leafSamples = this.extractNumericLeafSamples(path, sample.value);
|
|
219
|
+
if (leafSamples.length === 0) {
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
let recorded = 0;
|
|
223
|
+
leafSamples.forEach(leaf => {
|
|
224
|
+
const seriesKeys = this.enabledSeriesKeysByPath.get(leaf.path);
|
|
225
|
+
if (!seriesKeys || seriesKeys.length === 0) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
seriesKeys.forEach(seriesKey => {
|
|
229
|
+
const series = this.seriesById.get(seriesKey);
|
|
230
|
+
if (!series) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const seriesContext = series.context ?? 'vessels.self';
|
|
234
|
+
if (!this.isContextMatch(seriesContext, context)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const seriesSource = series.source ?? 'default';
|
|
238
|
+
if (!this.isSourceMatch(seriesSource, source)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (this.recordSampleByKey(seriesKey, leaf.value, ts)) {
|
|
242
|
+
recorded += 1;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
return recorded;
|
|
247
|
+
}
|
|
248
|
+
extractNumericLeafSamples(basePath, value) {
|
|
249
|
+
const samples = [];
|
|
250
|
+
const addNumeric = (samplePath, sampleValue) => {
|
|
251
|
+
const normalizedPath = this.normalizePathIdentifier(samplePath);
|
|
252
|
+
if (!normalizedPath) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const numericValue = Number(sampleValue);
|
|
256
|
+
if (!Number.isFinite(numericValue)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
samples.push({ path: normalizedPath, value: numericValue });
|
|
260
|
+
};
|
|
261
|
+
const walk = (currentPath, currentValue) => {
|
|
262
|
+
if (currentValue && typeof currentValue === 'object') {
|
|
263
|
+
if (Array.isArray(currentValue)) {
|
|
264
|
+
currentValue.forEach((entry, index) => {
|
|
265
|
+
walk(`${currentPath}.${index}`, entry);
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
Object.entries(currentValue).forEach(([key, child]) => {
|
|
270
|
+
walk(`${currentPath}.${key}`, child);
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
addNumeric(currentPath, currentValue);
|
|
275
|
+
};
|
|
276
|
+
walk(basePath, value);
|
|
277
|
+
return samples;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Returns all known history paths.
|
|
281
|
+
*
|
|
282
|
+
* @returns {string[]} Ordered unique path list.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* const paths = service.getPaths();
|
|
286
|
+
*/
|
|
287
|
+
getPaths() {
|
|
288
|
+
const paths = new Set();
|
|
289
|
+
this.seriesById.forEach(series => {
|
|
290
|
+
paths.add(series.path);
|
|
291
|
+
});
|
|
292
|
+
return Array.from(paths).sort();
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Returns all known history contexts.
|
|
296
|
+
*
|
|
297
|
+
* @returns {string[]} Ordered unique context list.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* const contexts = service.getContexts();
|
|
301
|
+
*/
|
|
302
|
+
getContexts() {
|
|
303
|
+
const contexts = new Set();
|
|
304
|
+
this.seriesById.forEach(series => {
|
|
305
|
+
contexts.add(series.context ?? 'vessels.self');
|
|
306
|
+
});
|
|
307
|
+
return Array.from(contexts).sort();
|
|
308
|
+
}
|
|
309
|
+
rebuildEnabledPathIndex() {
|
|
310
|
+
this.enabledSeriesKeysByPath.clear();
|
|
311
|
+
this.seriesById.forEach((series, seriesKey) => {
|
|
312
|
+
if (series.enabled === false) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const keys = this.enabledSeriesKeysByPath.get(series.path) ?? [];
|
|
316
|
+
keys.push(seriesKey);
|
|
317
|
+
this.enabledSeriesKeysByPath.set(series.path, keys);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
areSeriesEquivalent(left, right) {
|
|
321
|
+
const leftComparable = this.toComparableSeries(left);
|
|
322
|
+
const rightComparable = this.toComparableSeries(right);
|
|
323
|
+
return leftComparable.seriesId === rightComparable.seriesId
|
|
324
|
+
&& leftComparable.datasetUuid === rightComparable.datasetUuid
|
|
325
|
+
&& leftComparable.ownerWidgetUuid === rightComparable.ownerWidgetUuid
|
|
326
|
+
&& leftComparable.ownerWidgetSelector === rightComparable.ownerWidgetSelector
|
|
327
|
+
&& leftComparable.path === rightComparable.path
|
|
328
|
+
&& leftComparable.expansionMode === rightComparable.expansionMode
|
|
329
|
+
&& this.areStringArraysEquivalent(leftComparable.allowedBatteryIds, rightComparable.allowedBatteryIds)
|
|
330
|
+
&& leftComparable.source === rightComparable.source
|
|
331
|
+
&& leftComparable.context === rightComparable.context
|
|
332
|
+
&& leftComparable.timeScale === rightComparable.timeScale
|
|
333
|
+
&& leftComparable.period === rightComparable.period
|
|
334
|
+
&& leftComparable.retentionDurationMs === rightComparable.retentionDurationMs
|
|
335
|
+
&& leftComparable.sampleTime === rightComparable.sampleTime
|
|
336
|
+
&& leftComparable.enabled === rightComparable.enabled
|
|
337
|
+
&& this.areStringArraysEquivalent(leftComparable.methods, rightComparable.methods);
|
|
338
|
+
}
|
|
339
|
+
toComparableSeries(series) {
|
|
340
|
+
const { reconcileTs, ...comparable } = series;
|
|
341
|
+
void reconcileTs;
|
|
342
|
+
return {
|
|
343
|
+
...comparable,
|
|
344
|
+
allowedBatteryIds: this.normalizeComparableStringArray(comparable.allowedBatteryIds),
|
|
345
|
+
methods: this.normalizeComparableStringArray(comparable.methods)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
areStringArraysEquivalent(left, right) {
|
|
349
|
+
const normalizedLeft = this.normalizeComparableStringArray(left) ?? [];
|
|
350
|
+
const normalizedRight = this.normalizeComparableStringArray(right) ?? [];
|
|
351
|
+
if (normalizedLeft.length !== normalizedRight.length) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
|
355
|
+
}
|
|
356
|
+
normalizeComparableStringArray(values) {
|
|
357
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
return [...values]
|
|
361
|
+
.filter((value) => typeof value === 'string')
|
|
362
|
+
.sort((left, right) => left.localeCompare(right));
|
|
363
|
+
}
|
|
364
|
+
isChartWidget(ownerWidgetSelector, ownerWidgetUuid) {
|
|
365
|
+
if (ownerWidgetSelector === 'widget-data-chart' || ownerWidgetSelector === 'widget-windtrends-chart') {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
return ownerWidgetUuid?.startsWith('widget-windtrends-chart') === true
|
|
369
|
+
|| ownerWidgetUuid?.startsWith('widget-data-chart') === true;
|
|
370
|
+
}
|
|
371
|
+
normalizeSeries(input) {
|
|
372
|
+
const seriesId = (input.seriesId || input.datasetUuid || '').trim();
|
|
373
|
+
if (!seriesId) {
|
|
374
|
+
throw new Error('seriesId is required');
|
|
375
|
+
}
|
|
376
|
+
const datasetUuid = (input.datasetUuid || seriesId).trim();
|
|
377
|
+
if (!datasetUuid) {
|
|
378
|
+
throw new Error('datasetUuid is required');
|
|
379
|
+
}
|
|
380
|
+
const ownerWidgetUuid = (input.ownerWidgetUuid || '').trim();
|
|
381
|
+
if (!ownerWidgetUuid) {
|
|
382
|
+
throw new Error('ownerWidgetUuid is required');
|
|
383
|
+
}
|
|
384
|
+
const path = this.normalizePathIdentifier(input.path || '');
|
|
385
|
+
if (!path) {
|
|
386
|
+
throw new Error('path is required');
|
|
387
|
+
}
|
|
388
|
+
const ownerWidgetSelector = typeof input.ownerWidgetSelector === 'string' ? input.ownerWidgetSelector.trim() : null;
|
|
389
|
+
const expansionMode = input.expansionMode ?? null;
|
|
390
|
+
if (expansionMode === 'bms-battery-tree' && ownerWidgetSelector !== 'widget-bms') {
|
|
391
|
+
throw new Error('BMS template series must use ownerWidgetSelector "widget-bms"');
|
|
392
|
+
}
|
|
393
|
+
const normalizedMethods = this.normalizeComparableStringArray(input.methods);
|
|
394
|
+
const normalizedAllowedBatteryIds = expansionMode === 'bms-battery-tree'
|
|
395
|
+
? this.normalizeComparableStringArray(input.allowedBatteryIds)
|
|
396
|
+
: undefined;
|
|
397
|
+
const isDataWidget = this.isChartWidget(ownerWidgetSelector, ownerWidgetUuid);
|
|
398
|
+
const retentionMs = this.resolveRetentionMs(input);
|
|
399
|
+
let sampleTime;
|
|
400
|
+
if (isDataWidget) {
|
|
401
|
+
// For chart type widgets we use retention duration to dynamically calculate sampleTime to
|
|
402
|
+
// aims for around 120 samples.
|
|
403
|
+
sampleTime = retentionMs ? Math.max(1000, Math.round(retentionMs / 120)) : 1000;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// Non chart type widgets, ie historical Time-Series, have a fixed sampleTime of 15 sec that is
|
|
407
|
+
// a good median amount of samples for the dynamically queryable chart display range (15 min up to 24h).
|
|
408
|
+
sampleTime = 15000; // ms
|
|
409
|
+
}
|
|
410
|
+
const normalizedBase = {
|
|
411
|
+
seriesId,
|
|
412
|
+
datasetUuid,
|
|
413
|
+
ownerWidgetUuid,
|
|
414
|
+
ownerWidgetSelector,
|
|
415
|
+
path,
|
|
416
|
+
source: input.source ?? 'default',
|
|
417
|
+
context: input.context ?? 'vessels.self',
|
|
418
|
+
timeScale: input.timeScale ?? null,
|
|
419
|
+
period: Number.isFinite(input.period) ? input.period ?? null : null,
|
|
420
|
+
enabled: input.enabled !== false,
|
|
421
|
+
retentionDurationMs: retentionMs,
|
|
422
|
+
sampleTime,
|
|
423
|
+
methods: normalizedMethods,
|
|
424
|
+
reconcileTs: input.reconcileTs
|
|
425
|
+
};
|
|
426
|
+
if (expansionMode === 'bms-battery-tree') {
|
|
427
|
+
const templateSeries = {
|
|
428
|
+
...normalizedBase,
|
|
429
|
+
ownerWidgetSelector: 'widget-bms',
|
|
430
|
+
expansionMode,
|
|
431
|
+
allowedBatteryIds: normalizedAllowedBatteryIds ?? null
|
|
432
|
+
};
|
|
433
|
+
return templateSeries;
|
|
434
|
+
}
|
|
435
|
+
const concreteSeries = {
|
|
436
|
+
...normalizedBase,
|
|
437
|
+
expansionMode: null,
|
|
438
|
+
allowedBatteryIds: null
|
|
439
|
+
};
|
|
440
|
+
return concreteSeries;
|
|
441
|
+
}
|
|
442
|
+
resolveRetentionMs(series) {
|
|
443
|
+
if (Number.isFinite(series.retentionDurationMs) && series.retentionDurationMs > 0) {
|
|
444
|
+
return series.retentionDurationMs;
|
|
445
|
+
}
|
|
446
|
+
const period = Number(series.period ?? 0);
|
|
447
|
+
const scale = String(series.timeScale ?? '').toLowerCase();
|
|
448
|
+
if (scale === 'last minute')
|
|
449
|
+
return 60_000;
|
|
450
|
+
if (scale === 'last 5 minutes')
|
|
451
|
+
return 5 * 60_000;
|
|
452
|
+
if (scale === 'last 30 minutes')
|
|
453
|
+
return 30 * 60_000;
|
|
454
|
+
if (scale === 'second')
|
|
455
|
+
return Math.max(0, period) * 1_000;
|
|
456
|
+
if (scale === 'minute')
|
|
457
|
+
return Math.max(0, period) * 60_000;
|
|
458
|
+
if (scale === 'hour')
|
|
459
|
+
return Math.max(0, period) * 60 * 60_000;
|
|
460
|
+
if (scale === 'day')
|
|
461
|
+
return Math.max(0, period) * 24 * 60 * 60_000;
|
|
462
|
+
return 24 * 60 * 60_000;
|
|
463
|
+
}
|
|
464
|
+
normalizePathIdentifier(path) {
|
|
465
|
+
const trimmed = String(path).trim();
|
|
466
|
+
if (!trimmed) {
|
|
467
|
+
return '';
|
|
468
|
+
}
|
|
469
|
+
if (trimmed.startsWith('vessels.self.')) {
|
|
470
|
+
return trimmed.slice('vessels.self.'.length);
|
|
471
|
+
}
|
|
472
|
+
if (trimmed.startsWith('self.')) {
|
|
473
|
+
return trimmed.slice('self.'.length);
|
|
474
|
+
}
|
|
475
|
+
return trimmed;
|
|
476
|
+
}
|
|
477
|
+
resolveTimestamp(timestamp) {
|
|
478
|
+
if (typeof timestamp === 'number' && Number.isFinite(timestamp)) {
|
|
479
|
+
return timestamp;
|
|
480
|
+
}
|
|
481
|
+
if (typeof timestamp === 'string') {
|
|
482
|
+
const parsed = Date.parse(timestamp);
|
|
483
|
+
if (Number.isFinite(parsed)) {
|
|
484
|
+
return parsed;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return this.nowProvider();
|
|
488
|
+
}
|
|
489
|
+
resolveSource(sample) {
|
|
490
|
+
if (typeof sample.$source === 'string' && sample.$source.trim().length > 0) {
|
|
491
|
+
return sample.$source;
|
|
492
|
+
}
|
|
493
|
+
if (typeof sample.source === 'string' && sample.source.trim().length > 0) {
|
|
494
|
+
return sample.source;
|
|
495
|
+
}
|
|
496
|
+
if (sample.source && typeof sample.source === 'object') {
|
|
497
|
+
const source = sample.source;
|
|
498
|
+
const label = typeof source.label === 'string' ? source.label : '';
|
|
499
|
+
const src = typeof source.src === 'string' || typeof source.src === 'number' ? String(source.src) : '';
|
|
500
|
+
if (label && src) {
|
|
501
|
+
return `${label}.${src}`;
|
|
502
|
+
}
|
|
503
|
+
if (label) {
|
|
504
|
+
return label;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return 'default';
|
|
508
|
+
}
|
|
509
|
+
isContextMatch(seriesContext, sampleContext) {
|
|
510
|
+
if (seriesContext === sampleContext) {
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
if (this.isSelfContext(seriesContext) && this.isSelfContext(sampleContext)) {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
isSelfContext(context) {
|
|
519
|
+
if (context === 'vessels.self') {
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
return !!this.selfContext && context === this.selfContext;
|
|
523
|
+
}
|
|
524
|
+
isSourceMatch(seriesSource, sampleSource) {
|
|
525
|
+
if (seriesSource === '*' || seriesSource === 'any') {
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
if (seriesSource === 'default') {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
if (!sampleSource) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return seriesSource === sampleSource;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
exports.HistorySeriesService = HistorySeriesService;
|