@mxtommy/kip 4.5.0-beta.1 → 4.5.1

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +11 -7
  3. package/package.json +21 -8
  4. package/plugin/duckdb-parquet-storage.service.js +1182 -0
  5. package/plugin/history-series.service.js +439 -0
  6. package/plugin/index.js +705 -30
  7. package/plugin/openApi.json +253 -3
  8. package/plugin/plugin-auth.service.js +75 -0
  9. package/public/assets/help-docs/chartplotter.md +5 -18
  10. package/public/assets/help-docs/community.md +0 -3
  11. package/public/assets/help-docs/configuration.md +1 -1
  12. package/public/assets/help-docs/contact-us.md +0 -4
  13. package/public/assets/help-docs/dashboards.md +20 -18
  14. package/public/assets/help-docs/datainspector.md +7 -5
  15. package/public/assets/help-docs/history-api.md +116 -0
  16. package/public/assets/help-docs/menu.json +18 -6
  17. package/public/assets/help-docs/nodered-control-flows.md +125 -0
  18. package/public/assets/help-docs/putcontrols.md +101 -60
  19. package/public/assets/help-docs/welcome.md +6 -7
  20. package/public/assets/help-docs/widget-historical-series.md +66 -0
  21. package/public/assets/help-docs/zones.md +5 -10
  22. package/public/chunk-A6DQJFP4.js +16 -0
  23. package/public/chunk-B75MT7ND.js +1 -0
  24. package/public/{chunk-T6TFVZVM.js → chunk-CEB42O2C.js} +1 -1
  25. package/public/chunk-CHGXAEKT.js +2 -0
  26. package/public/chunk-D7VDX7ZF.js +5 -0
  27. package/public/{chunk-ZQER6AIQ.js → chunk-DEGYRCMI.js} +1 -1
  28. package/public/{chunk-M2B5OYGO.js → chunk-DEM56G4S.js} +1 -1
  29. package/public/chunk-DYTBBUMI.js +4 -0
  30. package/public/chunk-EQ2N7KDA.js +3 -0
  31. package/public/chunk-FNF7M3AE.js +1 -0
  32. package/public/chunk-IHURI4IH.js +5 -0
  33. package/public/{chunk-YIYYVDFO.js → chunk-IYRLINL7.js} +2 -2
  34. package/public/{chunk-5FEX27I4.js → chunk-JB4YVVNW.js} +1 -1
  35. package/public/chunk-JGGMFMY5.js +1 -0
  36. package/public/chunk-KPHICV76.js +5 -0
  37. package/public/{chunk-QZKCRH3H.js → chunk-KZ5DUKAX.js} +1 -1
  38. package/public/{chunk-HMOOTAEA.js → chunk-LQDSU4WS.js} +3 -3
  39. package/public/{chunk-IXQ7KIFY.js → chunk-MGPPVLZ7.js} +1 -1
  40. package/public/{chunk-QVCLOCEC.js → chunk-R7RQHWKJ.js} +1 -1
  41. package/public/chunk-RONXIZ2U.js +9 -0
  42. package/public/chunk-S72JTJPN.js +6 -0
  43. package/public/{chunk-KFFAA7DL.js → chunk-VCY32MWT.js} +8 -8
  44. package/public/chunk-YCEXTKGG.js +1 -0
  45. package/public/chunk-YKJKIWXO.js +6 -0
  46. package/public/chunk-ZV7IYYEQ.js +50 -0
  47. package/public/index.html +1 -1
  48. package/public/main-FQESQQV6.js +1 -0
  49. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -84
  50. package/.github/ISSUE_TEMPLATE/config.yml +0 -5
  51. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -35
  52. package/.github/copilot-instructions.md +0 -205
  53. package/.github/instructions/angular.instructions.md +0 -123
  54. package/.github/instructions/best-practices.instructions.md +0 -59
  55. package/.github/instructions/project.instructions.md +0 -432
  56. package/.github/workflows/ci.yml +0 -37
  57. package/docs/widget-schematic.md +0 -102
  58. package/images/ActionSidenav.png +0 -0
  59. package/images/ChartplotterMode.png +0 -0
  60. package/images/KIPDemo.png +0 -0
  61. package/images/KipBrightness-1024.png +0 -0
  62. package/images/KipConfig-Units-1024.png +0 -0
  63. package/images/KipConfig-display-1024x488.png +0 -0
  64. package/images/KipFreeboard-SK-1024.png +0 -0
  65. package/images/KipGaugeSample1-1024x545.png +0 -0
  66. package/images/KipGaugeSample2-1024x488.png +0 -0
  67. package/images/KipGaugeSample3-1024x508.png +0 -0
  68. package/images/KipNightMode-1024.png +0 -0
  69. package/images/KipWidgetConfig-layout-1024.png +0 -0
  70. package/images/KipWidgetConfig-paths-1024x488.png +0 -0
  71. package/images/Options.png +0 -0
  72. package/images/exterior_user_installs.png +0 -0
  73. package/images/formfactor.png +0 -0
  74. package/public/assets/help-docs/datasets.md +0 -95
  75. package/public/chunk-2OB7ZJBR.js +0 -3
  76. package/public/chunk-6GGJZDRE.js +0 -1
  77. package/public/chunk-6V4GGGXE.js +0 -2
  78. package/public/chunk-A5BW6BUM.js +0 -1
  79. package/public/chunk-DGE5YFPU.js +0 -5
  80. package/public/chunk-G6M3Z3BY.js +0 -53
  81. package/public/chunk-GMGZLXY7.js +0 -4
  82. package/public/chunk-GUZ3BDVZ.js +0 -2
  83. package/public/chunk-ICDGHQFP.js +0 -6
  84. package/public/chunk-JCNE4QHQ.js +0 -15
  85. package/public/chunk-K6XYUNG4.js +0 -8
  86. package/public/chunk-LGCQEN7V.js +0 -4
  87. package/public/chunk-O3JH7UTR.js +0 -1
  88. package/public/chunk-Q3USFT4F.js +0 -2
  89. package/public/chunk-VIKU7BH7.js +0 -1
  90. package/public/chunk-XMQPXXLW.js +0 -8
  91. package/public/main-4URMGBQS.js +0 -1
  92. package/rm-npmjs-beta.sh +0 -50
  93. package/tools/schematics/collection.json +0 -9
  94. package/tools/schematics/create-host2-widget/files/readme/README.md.template +0 -109
  95. package/tools/schematics/create-host2-widget/files/spec/widget-__name@dasherize__.component.spec.ts +0 -38
  96. package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.html +0 -6
  97. package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.scss +0 -5
  98. package/tools/schematics/create-host2-widget/files/widget/widget-__name@dasherize__.component.ts.template +0 -94
  99. package/tools/schematics/create-host2-widget/index.js +0 -138
  100. package/tools/schematics/create-host2-widget/schema.json +0 -89
  101. package/tools/schematics/create-host2-widget/test/create-host2-widget.spec.ts +0 -70
  102. package/tools/schematics/create-host2-widget/utils/formatting.js +0 -119
@@ -0,0 +1,439 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HistorySeriesService = void 0;
4
+ /**
5
+ * Manages history capture series definitions and serves History API-compatible query results.
6
+ */
7
+ class HistorySeriesService {
8
+ nowProvider;
9
+ retainSamplesInMemory;
10
+ seriesById = new Map();
11
+ lastAcceptedTimestampBySeriesKey = new Map();
12
+ sampleSink = null;
13
+ constructor(nowProvider = () => Date.now(), retainSamplesInMemory = true) {
14
+ this.nowProvider = nowProvider;
15
+ this.retainSamplesInMemory = retainSamplesInMemory;
16
+ }
17
+ /**
18
+ * Returns all configured series sorted by `seriesId`.
19
+ *
20
+ * @returns Ordered list of series definitions.
21
+ *
22
+ * @example
23
+ * const list = service.listSeries();
24
+ * console.log(list.length);
25
+ */
26
+ listSeries() {
27
+ return Array.from(this.seriesById.values()).sort((left, right) => {
28
+ return left.seriesId.localeCompare(right.seriesId);
29
+ });
30
+ }
31
+ /**
32
+ * Finds a series by identifier.
33
+ *
34
+ * @param {string} seriesId Series identifier.
35
+ * @returns {ISeriesDefinition | null} Matching series or null.
36
+ *
37
+ * @example
38
+ * const row = service.findSeriesById('series-1');
39
+ */
40
+ findSeriesById(seriesId) {
41
+ return this.seriesById.get(seriesId) ?? null;
42
+ }
43
+ /**
44
+ * Creates or updates a series definition.
45
+ *
46
+ * @param {ISeriesDefinition} input The incoming series definition payload.
47
+ * @returns {ISeriesDefinition} The normalized and stored series definition.
48
+ *
49
+ * @example
50
+ * service.upsertSeries({
51
+ * seriesId: 'abc',
52
+ * datasetUuid: 'abc',
53
+ * ownerWidgetUuid: 'widget-1',
54
+ * path: 'navigation.speedOverGround'
55
+ * });
56
+ */
57
+ upsertSeries(input) {
58
+ const normalized = this.normalizeSeries(input);
59
+ const key = normalized.seriesId;
60
+ this.seriesById.set(key, normalized);
61
+ return normalized;
62
+ }
63
+ /**
64
+ * Registers a callback invoked for every accepted sample.
65
+ *
66
+ * @param {(sample: IRecordedSeriesSample) => void | null} sink Callback used to forward samples to persistent storage.
67
+ * @returns {void}
68
+ *
69
+ * @example
70
+ * service.setSampleSink(sample => console.log(sample.seriesId));
71
+ */
72
+ setSampleSink(sink) {
73
+ this.sampleSink = sink;
74
+ }
75
+ /**
76
+ * Deletes an existing series definition and all captured samples for the series.
77
+ *
78
+ * @param {string} seriesId Unique series identifier.
79
+ * @returns {boolean} True when a series existed and was deleted.
80
+ *
81
+ * @example
82
+ * const deleted = service.deleteSeries('abc');
83
+ */
84
+ deleteSeries(seriesId) {
85
+ const keysToDelete = Array.from(this.seriesById.entries())
86
+ .filter(([, series]) => series.seriesId === seriesId)
87
+ .map(([key]) => key);
88
+ if (keysToDelete.length === 0) {
89
+ return false;
90
+ }
91
+ keysToDelete.forEach(key => {
92
+ this.seriesById.delete(key);
93
+ this.lastAcceptedTimestampBySeriesKey.delete(key);
94
+ });
95
+ return true;
96
+ }
97
+ /**
98
+ * Reconciles the entire desired series set against the current state.
99
+ *
100
+ * @param {ISeriesDefinition[]} desiredSeries Full desired series payload list.
101
+ * @returns {{ created: number; updated: number; deleted: number; total: number }} Reconciliation summary.
102
+ *
103
+ * @example
104
+ * const result = service.reconcileSeries([{ seriesId: 's1', datasetUuid: 's1', ownerWidgetUuid: 'w1', path: 'p' }]);
105
+ * console.log(result.created, result.deleted);
106
+ */
107
+ reconcileSeries(desiredSeries) {
108
+ const now = Date.now();
109
+ const desiredById = new Map();
110
+ desiredSeries.forEach(entry => {
111
+ const normalized = this.normalizeSeries(entry);
112
+ const key = normalized.seriesId;
113
+ desiredById.set(key, normalized);
114
+ });
115
+ let created = 0;
116
+ let updated = 0;
117
+ let deleted = 0;
118
+ desiredById.forEach((desired, seriesKey) => {
119
+ const existing = this.seriesById.get(seriesKey);
120
+ // Always update reconcile_ts on reconcile
121
+ const desiredWithReconcile = { ...desired, reconcileTs: now };
122
+ if (!existing) {
123
+ this.seriesById.set(seriesKey, desiredWithReconcile);
124
+ created += 1;
125
+ return;
126
+ }
127
+ if (JSON.stringify(existing) !== JSON.stringify(desired)) {
128
+ this.seriesById.set(seriesKey, desiredWithReconcile);
129
+ updated += 1;
130
+ }
131
+ else {
132
+ // Even if not updated, update reconcileTs
133
+ this.seriesById.set(seriesKey, { ...existing, reconcileTs: now });
134
+ }
135
+ });
136
+ Array.from(this.seriesById.keys()).forEach(seriesKey => {
137
+ if (!desiredById.has(seriesKey)) {
138
+ this.seriesById.delete(seriesKey);
139
+ this.lastAcceptedTimestampBySeriesKey.delete(seriesKey);
140
+ deleted += 1;
141
+ }
142
+ });
143
+ return {
144
+ created,
145
+ updated,
146
+ deleted,
147
+ total: this.seriesById.size
148
+ };
149
+ }
150
+ /**
151
+ * Records a single numeric sample for a configured series.
152
+ *
153
+ * @param {string} seriesId Unique series identifier.
154
+ * @param {number} value Numeric sample value.
155
+ * @param {number} timestamp Sample timestamp in milliseconds.
156
+ * @returns {boolean} True when sample was accepted.
157
+ *
158
+ * @example
159
+ * service.recordSample('abc', 12.4, Date.now());
160
+ */
161
+ recordSample(seriesId, value, timestamp) {
162
+ const seriesEntry = Array.from(this.seriesById.entries())
163
+ .find(([, series]) => series.seriesId === seriesId);
164
+ if (!seriesEntry) {
165
+ return false;
166
+ }
167
+ return this.recordSampleByKey(seriesEntry[0], value, timestamp);
168
+ }
169
+ recordSampleByKey(seriesKey, value, timestamp) {
170
+ const series = this.seriesById.get(seriesKey);
171
+ if (!series || series.enabled === false || !Number.isFinite(value) || !Number.isFinite(timestamp)) {
172
+ return false;
173
+ }
174
+ const samplingIntervalMs = this.resolveSampleTimeMs(series.sampleTime);
175
+ const previousTimestamp = this.lastAcceptedTimestampBySeriesKey.get(seriesKey);
176
+ if (previousTimestamp !== undefined && (timestamp - previousTimestamp) < samplingIntervalMs) {
177
+ return false;
178
+ }
179
+ const context = series.context ?? 'vessels.self';
180
+ const source = series.source ?? 'default';
181
+ this.sampleSink?.({
182
+ seriesId: series.seriesId,
183
+ datasetUuid: series.datasetUuid,
184
+ ownerWidgetUuid: series.ownerWidgetUuid,
185
+ path: series.path,
186
+ context,
187
+ source,
188
+ timestamp,
189
+ value
190
+ });
191
+ this.lastAcceptedTimestampBySeriesKey.set(seriesKey, timestamp);
192
+ return true;
193
+ }
194
+ /**
195
+ * Records a sample based on a Signal K stream value by matching path/context/source against configured series.
196
+ *
197
+ * @param {{ path?: unknown; value?: unknown; timestamp?: unknown; context?: unknown; source?: unknown; $source?: unknown }} sample Signal K normalized value entry.
198
+ * @returns {number} Number of configured series that accepted the sample.
199
+ *
200
+ * @example
201
+ * const count = service.recordFromSignalKSample({ path: 'navigation.speedOverGround', value: 6.2, timestamp: new Date().toISOString() });
202
+ */
203
+ recordFromSignalKSample(sample) {
204
+ const path = this.normalizePathIdentifier(typeof sample.path === 'string' ? sample.path : '');
205
+ if (!path) {
206
+ return 0;
207
+ }
208
+ const ts = this.resolveTimestamp(sample.timestamp);
209
+ const context = typeof sample.context === 'string' && sample.context ? sample.context : 'vessels.self';
210
+ const source = this.resolveSource(sample);
211
+ const leafSamples = this.extractNumericLeafSamples(path, sample.value);
212
+ if (leafSamples.length === 0) {
213
+ return 0;
214
+ }
215
+ let recorded = 0;
216
+ leafSamples.forEach(leaf => {
217
+ this.seriesById.forEach((series, seriesKey) => {
218
+ if (series.path !== leaf.path || series.enabled === false) {
219
+ return;
220
+ }
221
+ const seriesContext = series.context ?? 'vessels.self';
222
+ if (!this.isContextMatch(seriesContext, context)) {
223
+ return;
224
+ }
225
+ const seriesSource = series.source ?? 'default';
226
+ if (!this.isSourceMatch(seriesSource, source)) {
227
+ return;
228
+ }
229
+ if (this.recordSampleByKey(seriesKey, leaf.value, ts)) {
230
+ recorded += 1;
231
+ }
232
+ });
233
+ });
234
+ return recorded;
235
+ }
236
+ extractNumericLeafSamples(basePath, value) {
237
+ const samples = [];
238
+ const addNumeric = (samplePath, sampleValue) => {
239
+ const normalizedPath = this.normalizePathIdentifier(samplePath);
240
+ if (!normalizedPath) {
241
+ return;
242
+ }
243
+ const numericValue = Number(sampleValue);
244
+ if (!Number.isFinite(numericValue)) {
245
+ return;
246
+ }
247
+ samples.push({ path: normalizedPath, value: numericValue });
248
+ };
249
+ const walk = (currentPath, currentValue) => {
250
+ if (currentValue && typeof currentValue === 'object') {
251
+ if (Array.isArray(currentValue)) {
252
+ currentValue.forEach((entry, index) => {
253
+ walk(`${currentPath}.${index}`, entry);
254
+ });
255
+ return;
256
+ }
257
+ Object.entries(currentValue).forEach(([key, child]) => {
258
+ walk(`${currentPath}.${key}`, child);
259
+ });
260
+ return;
261
+ }
262
+ addNumeric(currentPath, currentValue);
263
+ };
264
+ walk(basePath, value);
265
+ return samples;
266
+ }
267
+ /**
268
+ * Returns all known history paths.
269
+ *
270
+ * @returns {string[]} Ordered unique path list.
271
+ *
272
+ * @example
273
+ * const paths = service.getPaths();
274
+ */
275
+ getPaths() {
276
+ const paths = new Set();
277
+ this.seriesById.forEach(series => {
278
+ paths.add(series.path);
279
+ });
280
+ return Array.from(paths).sort();
281
+ }
282
+ /**
283
+ * Returns all known history contexts.
284
+ *
285
+ * @returns {string[]} Ordered unique context list.
286
+ *
287
+ * @example
288
+ * const contexts = service.getContexts();
289
+ */
290
+ getContexts() {
291
+ const contexts = new Set();
292
+ this.seriesById.forEach(series => {
293
+ contexts.add(series.context ?? 'vessels.self');
294
+ });
295
+ return Array.from(contexts).sort();
296
+ }
297
+ normalizeSeries(input) {
298
+ const seriesId = (input.seriesId || input.datasetUuid || '').trim();
299
+ if (!seriesId) {
300
+ throw new Error('seriesId is required');
301
+ }
302
+ const datasetUuid = (input.datasetUuid || seriesId).trim();
303
+ if (!datasetUuid) {
304
+ throw new Error('datasetUuid is required');
305
+ }
306
+ const ownerWidgetUuid = (input.ownerWidgetUuid || '').trim();
307
+ if (!ownerWidgetUuid) {
308
+ throw new Error('ownerWidgetUuid is required');
309
+ }
310
+ const path = this.normalizePathIdentifier(input.path || '');
311
+ if (!path) {
312
+ throw new Error('path is required');
313
+ }
314
+ // Determine if this is a data chart based widget
315
+ const dsSampleTime = ownerWidgetUuid?.startsWith('widget-windtrends-chart') ||
316
+ ownerWidgetUuid?.startsWith('widget-data-chart');
317
+ let sampleTime;
318
+ const retentionMs = input.retentionDurationMs ?? this.resolveRetentionMs(input);
319
+ if (dsSampleTime && Number.isFinite(retentionMs) && retentionMs > 0) {
320
+ // regular widget sampleTime: 15 sec
321
+ sampleTime = Math.max(1, Math.trunc(15000));
322
+ }
323
+ else {
324
+ sampleTime = this.resolveSampleTimeMs(input.sampleTime);
325
+ }
326
+ return {
327
+ ...input,
328
+ seriesId,
329
+ datasetUuid,
330
+ ownerWidgetUuid,
331
+ path,
332
+ source: input.source ?? 'default',
333
+ context: input.context ?? 'vessels.self',
334
+ enabled: input.enabled !== false,
335
+ retentionDurationMs: retentionMs,
336
+ sampleTime
337
+ };
338
+ }
339
+ resolveSampleTimeMs(sampleTime) {
340
+ const parsed = Number(sampleTime);
341
+ if (Number.isFinite(parsed) && parsed > 0) {
342
+ return Math.max(1, Math.trunc(parsed));
343
+ }
344
+ return 1000;
345
+ }
346
+ buildSeriesMapKey(seriesId) {
347
+ // userScope removed; now returns only seriesId
348
+ return String(seriesId).trim();
349
+ }
350
+ resolveRetentionMs(series) {
351
+ if (Number.isFinite(series.retentionDurationMs) && series.retentionDurationMs > 0) {
352
+ return series.retentionDurationMs;
353
+ }
354
+ const period = Number(series.period ?? 0);
355
+ const scale = String(series.timeScale ?? '').toLowerCase();
356
+ if (scale === 'last minute')
357
+ return 60_000;
358
+ if (scale === 'last 5 minutes')
359
+ return 5 * 60_000;
360
+ if (scale === 'last 30 minutes')
361
+ return 30 * 60_000;
362
+ if (scale === 'second')
363
+ return Math.max(0, period) * 1_000;
364
+ if (scale === 'minute')
365
+ return Math.max(0, period) * 60_000;
366
+ if (scale === 'hour')
367
+ return Math.max(0, period) * 60 * 60_000;
368
+ if (scale === 'day')
369
+ return Math.max(0, period) * 24 * 60 * 60_000;
370
+ return 24 * 60 * 60_000;
371
+ }
372
+ normalizePathIdentifier(path) {
373
+ const trimmed = String(path).trim();
374
+ if (!trimmed) {
375
+ return '';
376
+ }
377
+ if (trimmed.startsWith('vessels.self.')) {
378
+ return trimmed.slice('vessels.self.'.length);
379
+ }
380
+ if (trimmed.startsWith('self.')) {
381
+ return trimmed.slice('self.'.length);
382
+ }
383
+ return trimmed;
384
+ }
385
+ resolveTimestamp(timestamp) {
386
+ if (typeof timestamp === 'number' && Number.isFinite(timestamp)) {
387
+ return timestamp;
388
+ }
389
+ if (typeof timestamp === 'string') {
390
+ const parsed = Date.parse(timestamp);
391
+ if (Number.isFinite(parsed)) {
392
+ return parsed;
393
+ }
394
+ }
395
+ return this.nowProvider();
396
+ }
397
+ resolveSource(sample) {
398
+ if (typeof sample.$source === 'string' && sample.$source.trim().length > 0) {
399
+ return sample.$source;
400
+ }
401
+ if (typeof sample.source === 'string' && sample.source.trim().length > 0) {
402
+ return sample.source;
403
+ }
404
+ if (sample.source && typeof sample.source === 'object') {
405
+ const source = sample.source;
406
+ const label = typeof source.label === 'string' ? source.label : '';
407
+ const src = typeof source.src === 'string' || typeof source.src === 'number' ? String(source.src) : '';
408
+ if (label && src) {
409
+ return `${label}.${src}`;
410
+ }
411
+ if (label) {
412
+ return label;
413
+ }
414
+ }
415
+ return 'default';
416
+ }
417
+ isContextMatch(seriesContext, sampleContext) {
418
+ if (seriesContext === sampleContext) {
419
+ return true;
420
+ }
421
+ if (seriesContext === 'vessels.self') {
422
+ return sampleContext === 'vessels.self' || sampleContext.startsWith('vessels.');
423
+ }
424
+ return false;
425
+ }
426
+ isSourceMatch(seriesSource, sampleSource) {
427
+ if (seriesSource === '*' || seriesSource === 'any') {
428
+ return true;
429
+ }
430
+ if (seriesSource === 'default') {
431
+ return true;
432
+ }
433
+ if (!sampleSource) {
434
+ return false;
435
+ }
436
+ return seriesSource === sampleSource;
437
+ }
438
+ }
439
+ exports.HistorySeriesService = HistorySeriesService;