@mxtommy/kip 4.5.0-beta.1 → 4.5.0
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/.github/copilot-instructions.md +19 -6
- package/.github/instructions/project.instructions.md +42 -6
- package/CHANGELOG.md +13 -0
- package/README.md +11 -7
- package/package.json +8 -5
- package/plugin/duckdb-parquet-storage.service.js +1206 -0
- package/plugin/history-series.service.js +439 -0
- package/plugin/index.js +670 -15
- package/plugin/openApi.json +253 -3
- package/plugin/plugin-auth.service.js +75 -0
- package/plugin-config-data/kip/historicalData/kip-history.duckdb +0 -0
- package/plugin-config-data/kip/historicalData/parquet/chart-1/1772344583976-1772344583976.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1771408800000-1771408890000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1771412400000-1771412490000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584154-1772344584154.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584191-1772344584191.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-1/1772344584268-1772344584268.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-2/1771502400000-1771502400000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-3/1771408800000-1771408890000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-3/1771412400000-1771412490000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-3/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-3/1772344584268-1772344584268.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-4/1771408800000-1771408890000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-4/1771412400000-1771412490000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-4/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-5/1771412400000-1771412490000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-5/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-6/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771408800000-1771408890000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771412400000-1771412490000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1771419600000-1771419650000.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1772344584191-1772344584191.parquet +0 -0
- package/plugin-config-data/kip/historicalData/parquet/live-prefixed-1/1772344584268-1772344584268.parquet +0 -0
- package/public/assets/help-docs/chartplotter.md +5 -18
- package/public/assets/help-docs/community.md +0 -3
- package/public/assets/help-docs/configuration.md +1 -1
- package/public/assets/help-docs/contact-us.md +0 -4
- package/public/assets/help-docs/dashboards.md +20 -18
- package/public/assets/help-docs/datainspector.md +7 -5
- package/public/assets/help-docs/history-api.md +116 -0
- package/public/assets/help-docs/menu.json +18 -6
- package/public/assets/help-docs/nodered-control-flows.md +125 -0
- package/public/assets/help-docs/putcontrols.md +101 -60
- package/public/assets/help-docs/welcome.md +6 -7
- package/public/assets/help-docs/widget-historical-series.md +66 -0
- package/public/assets/help-docs/zones.md +5 -10
- package/public/{chunk-KFFAA7DL.js → chunk-2ICAVOT2.js} +8 -8
- package/public/chunk-6XFWUUDD.js +3 -0
- package/public/chunk-A6DQJFP4.js +16 -0
- package/public/chunk-B75MT7ND.js +1 -0
- package/public/{chunk-T6TFVZVM.js → chunk-CEB42O2C.js} +1 -1
- package/public/chunk-CHGXAEKT.js +2 -0
- package/public/chunk-D7VDX7ZF.js +5 -0
- package/public/chunk-DD4F6F4S.js +9 -0
- package/public/{chunk-ZQER6AIQ.js → chunk-DEGYRCMI.js} +1 -1
- package/public/{chunk-M2B5OYGO.js → chunk-DEM56G4S.js} +1 -1
- package/public/chunk-DYTBBUMI.js +4 -0
- package/public/{chunk-YIYYVDFO.js → chunk-EDNYYQIZ.js} +2 -2
- package/public/chunk-FNF7M3AE.js +1 -0
- package/public/chunk-IHURI4IH.js +5 -0
- package/public/chunk-J3LDKVIS.js +50 -0
- package/public/{chunk-5FEX27I4.js → chunk-JB4YVVNW.js} +1 -1
- package/public/chunk-KPHICV76.js +5 -0
- package/public/{chunk-QZKCRH3H.js → chunk-KZ5DUKAX.js} +1 -1
- package/public/{chunk-HMOOTAEA.js → chunk-LQDSU4WS.js} +3 -3
- package/public/{chunk-IXQ7KIFY.js → chunk-MGPPVLZ7.js} +1 -1
- package/public/{chunk-QVCLOCEC.js → chunk-R7RQHWKJ.js} +1 -1
- package/public/chunk-S72JTJPN.js +6 -0
- package/public/chunk-UYIJND2R.js +1 -0
- package/public/chunk-YCEXTKGG.js +1 -0
- package/public/chunk-YKJKIWXO.js +6 -0
- package/public/index.html +1 -1
- package/public/main-EG2WF4EO.js +1 -0
- package/tools/schematics/create-host2-widget/files/readme/README.md.template +1 -1
- package/public/assets/help-docs/datasets.md +0 -95
- package/public/chunk-2OB7ZJBR.js +0 -3
- package/public/chunk-6GGJZDRE.js +0 -1
- package/public/chunk-6V4GGGXE.js +0 -2
- package/public/chunk-A5BW6BUM.js +0 -1
- package/public/chunk-DGE5YFPU.js +0 -5
- package/public/chunk-G6M3Z3BY.js +0 -53
- package/public/chunk-GMGZLXY7.js +0 -4
- package/public/chunk-GUZ3BDVZ.js +0 -2
- package/public/chunk-ICDGHQFP.js +0 -6
- package/public/chunk-JCNE4QHQ.js +0 -15
- package/public/chunk-K6XYUNG4.js +0 -8
- package/public/chunk-LGCQEN7V.js +0 -4
- package/public/chunk-O3JH7UTR.js +0 -1
- package/public/chunk-Q3USFT4F.js +0 -2
- package/public/chunk-VIKU7BH7.js +0 -1
- package/public/chunk-XMQPXXLW.js +0 -8
- package/public/main-4URMGBQS.js +0 -1
- package/rm-npmjs-beta.sh +0 -50
package/plugin/index.js
CHANGED
|
@@ -35,13 +35,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
const server_api_1 = require("@signalk/server-api");
|
|
37
37
|
const openapi = __importStar(require("./openApi.json"));
|
|
38
|
+
const history_series_service_1 = require("./history-series.service");
|
|
39
|
+
const duckdb_parquet_storage_service_1 = require("./duckdb-parquet-storage.service");
|
|
38
40
|
const start = (server) => {
|
|
39
41
|
const mutableOpenApi = JSON.parse(JSON.stringify(openapi.default ?? openapi));
|
|
40
42
|
const API_PATHS = {
|
|
41
43
|
DISPLAYS: `/displays`,
|
|
42
44
|
INSTANCE: `/displays/:displayId`,
|
|
43
45
|
SCREEN_INDEX: `/displays/:displayId/screenIndex`,
|
|
44
|
-
ACTIVATE_SCREEN: `/displays/:displayId/activeScreen
|
|
46
|
+
ACTIVATE_SCREEN: `/displays/:displayId/activeScreen`,
|
|
47
|
+
SERIES: '/series',
|
|
48
|
+
SERIES_INSTANCE: '/series/:seriesId',
|
|
49
|
+
SERIES_RECONCILE: '/series/reconcile',
|
|
45
50
|
};
|
|
46
51
|
const PUT_CONTEXT = 'vessels.self';
|
|
47
52
|
const COMMAND_PATHS = {
|
|
@@ -50,14 +55,48 @@ const start = (server) => {
|
|
|
50
55
|
REQUEST_ACTIVE_SCREEN: 'kip.remote.requestActiveScreen'
|
|
51
56
|
};
|
|
52
57
|
const CONFIG_SCHEMA = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
title: 'Remote Control and Data Series',
|
|
60
|
+
description: 'NOTE: All plugin settings are also managed from within KIP\'s Display Options panel. Changes made here will be overridden when KIP applies settings from the Display Options.',
|
|
53
61
|
properties: {
|
|
54
|
-
|
|
55
|
-
type: '
|
|
56
|
-
title: '
|
|
57
|
-
description: '
|
|
62
|
+
historySeriesServiceEnabled: {
|
|
63
|
+
type: 'boolean',
|
|
64
|
+
title: 'Enable Automatic Historical Time-Series Capture and Management',
|
|
65
|
+
description: 'Historical Time-Series are data captures that supply the widget historical data and seed the Data Chart and Wind Trends widgets. If disabled, data capture must be configured in your chosen history provider plugin.',
|
|
66
|
+
default: true
|
|
67
|
+
},
|
|
68
|
+
registerAsHistoryApiProvider: {
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
title: 'Enable Query Provider',
|
|
71
|
+
description: 'The built-in History-API provider is the querying engine that uses the Historical Time-Series data to seed the widget historical panel, the Data Chart and Wind Trends widgets. If you want to use another History-API provider, turn this off and configure your chosen History-API compatible provider accordingly and KIP will query that provider.',
|
|
72
|
+
default: true
|
|
58
73
|
}
|
|
59
74
|
}
|
|
60
75
|
};
|
|
76
|
+
const historySeries = new history_series_service_1.HistorySeriesService(() => Date.now(), false);
|
|
77
|
+
const storageService = new duckdb_parquet_storage_service_1.DuckDbParquetStorageService();
|
|
78
|
+
let retentionSweepTimer = null;
|
|
79
|
+
let storageFlushTimer = null;
|
|
80
|
+
let duckDbInitializationPromise = null;
|
|
81
|
+
const DUCKDB_INIT_WAIT_TIMEOUT_MS = 5000;
|
|
82
|
+
let streamUnsubscribes = [];
|
|
83
|
+
let historyApiRegistry = null;
|
|
84
|
+
let historySeriesServiceEnabled = true;
|
|
85
|
+
let registerAsHistoryApiProvider = true;
|
|
86
|
+
let historyApiProviderRegistered = false;
|
|
87
|
+
function resolveHistoryModeConfig(settings) {
|
|
88
|
+
const root = (settings && typeof settings === 'object' ? settings : {});
|
|
89
|
+
const historySeriesServiceEnabledSetting = typeof root.historySeriesServiceEnabled === 'boolean'
|
|
90
|
+
? root.historySeriesServiceEnabled
|
|
91
|
+
: undefined;
|
|
92
|
+
const registerAsHistoryApiProviderSetting = typeof root.registerAsHistoryApiProvider === 'boolean'
|
|
93
|
+
? root.registerAsHistoryApiProvider
|
|
94
|
+
: undefined;
|
|
95
|
+
return {
|
|
96
|
+
historySeriesServiceEnabled: historySeriesServiceEnabledSetting !== false,
|
|
97
|
+
registerAsHistoryApiProvider: registerAsHistoryApiProviderSetting !== false
|
|
98
|
+
};
|
|
99
|
+
}
|
|
61
100
|
// Helpers
|
|
62
101
|
function getDisplaySelfPath(displayId, suffix) {
|
|
63
102
|
const tail = suffix ? `.${suffix}` : '';
|
|
@@ -79,6 +118,92 @@ const start = (server) => {
|
|
|
79
118
|
function sendFail(res, statusCode, message) {
|
|
80
119
|
return res.status(statusCode).json({ state: 'FAILED', statusCode, message });
|
|
81
120
|
}
|
|
121
|
+
function getHistorySeriesServiceDisabledMessage() {
|
|
122
|
+
return 'KIP history-series service is disabled by plugin configuration';
|
|
123
|
+
}
|
|
124
|
+
function getDuckDbUnavailableMessage() {
|
|
125
|
+
const details = storageService.getLastInitError();
|
|
126
|
+
return details
|
|
127
|
+
? `DuckDB storage unavailable: ${details}`
|
|
128
|
+
: 'DuckDB storage unavailable';
|
|
129
|
+
}
|
|
130
|
+
function isDuckDbUnavailable() {
|
|
131
|
+
return Boolean(storageService.getLastInitError());
|
|
132
|
+
}
|
|
133
|
+
async function waitForDuckDbInitialization(timeoutMs = DUCKDB_INIT_WAIT_TIMEOUT_MS) {
|
|
134
|
+
if (!duckDbInitializationPromise) {
|
|
135
|
+
return storageService.isDuckDbParquetReady();
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const ready = await Promise.race([
|
|
139
|
+
duckDbInitializationPromise,
|
|
140
|
+
new Promise(resolvePromise => {
|
|
141
|
+
setTimeout(() => resolvePromise(false), timeoutMs);
|
|
142
|
+
})
|
|
143
|
+
]);
|
|
144
|
+
if (!ready && !storageService.isDuckDbParquetReady()) {
|
|
145
|
+
server.error(`[SERIES STORAGE] DuckDB initialization wait timed out after ${timeoutMs}ms`);
|
|
146
|
+
}
|
|
147
|
+
return ready;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function getRouteError(error, fallbackMessage) {
|
|
154
|
+
const message = String(error?.message || fallbackMessage);
|
|
155
|
+
const normalized = message.toLowerCase();
|
|
156
|
+
if (normalized.includes('invalid ')
|
|
157
|
+
|| normalized.includes('missing ')
|
|
158
|
+
|| normalized.includes('body must')
|
|
159
|
+
|| normalized.includes('required')
|
|
160
|
+
|| normalized.includes('expected an iso')) {
|
|
161
|
+
return { statusCode: 400, message };
|
|
162
|
+
}
|
|
163
|
+
if (normalized.includes('duckdb')
|
|
164
|
+
|| normalized.includes('storage unavailable')
|
|
165
|
+
|| normalized.includes('not initialized')
|
|
166
|
+
|| isDuckDbUnavailable()) {
|
|
167
|
+
return { statusCode: 503, message };
|
|
168
|
+
}
|
|
169
|
+
return { statusCode: 500, message };
|
|
170
|
+
}
|
|
171
|
+
function findSeriesById(seriesId) {
|
|
172
|
+
const current = historySeries.findSeriesById(seriesId);
|
|
173
|
+
return current ? JSON.parse(JSON.stringify(current)) : null;
|
|
174
|
+
}
|
|
175
|
+
function isHistorySeriesServiceEnabled() {
|
|
176
|
+
return historySeriesServiceEnabled;
|
|
177
|
+
}
|
|
178
|
+
function isHistoryApiProviderEnabled() {
|
|
179
|
+
return registerAsHistoryApiProvider;
|
|
180
|
+
}
|
|
181
|
+
function logOperationalMode(stage) {
|
|
182
|
+
server.debug(`[HISTORY MODE] stage=${stage} historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
|
|
183
|
+
}
|
|
184
|
+
async function ensureDuckDbReadyForRequest(res) {
|
|
185
|
+
await waitForDuckDbInitialization();
|
|
186
|
+
if (storageService.isDuckDbParquetReady()) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
sendFail(res, 503, getDuckDbUnavailableMessage());
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
function ensureHistorySeriesServiceEnabledForRequest(res) {
|
|
193
|
+
if (isHistorySeriesServiceEnabled()) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
sendFail(res, 503, getHistorySeriesServiceDisabledMessage());
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
function logAuthTrace(req, stage) {
|
|
200
|
+
const hasAuthorizationHeader = typeof req.headers.authorization === 'string' && req.headers.authorization.length > 0;
|
|
201
|
+
const hasCookieHeader = typeof req.headers.cookie === 'string' && req.headers.cookie.length > 0;
|
|
202
|
+
const origin = req.headers.origin ?? null;
|
|
203
|
+
const userAgent = req.headers['user-agent'] ?? null;
|
|
204
|
+
const contentType = req.headers['content-type'] ?? null;
|
|
205
|
+
server.debug(`[AUTH TRACE] stage=${stage} method=${req.method} path=${req.path} ip=${req.ip} origin=${String(origin)} authHeader=${hasAuthorizationHeader} cookieHeader=${hasCookieHeader} contentType=${String(contentType)} userAgent=${String(userAgent)}`);
|
|
206
|
+
}
|
|
82
207
|
function completed(statusCode, message) {
|
|
83
208
|
return { state: 'COMPLETED', statusCode, message };
|
|
84
209
|
}
|
|
@@ -87,6 +212,7 @@ const start = (server) => {
|
|
|
87
212
|
}
|
|
88
213
|
function applyDisplayWrite(displayId, suffix, value) {
|
|
89
214
|
const path = suffix ? `displays.${displayId}.${suffix}` : `displays.${displayId}`;
|
|
215
|
+
server.debug(`[WRITE TRACE] applyDisplayWrite path=${path} value=${JSON.stringify(value)}`);
|
|
90
216
|
try {
|
|
91
217
|
server.handleMessage(plugin.id, {
|
|
92
218
|
updates: [
|
|
@@ -100,14 +226,17 @@ const start = (server) => {
|
|
|
100
226
|
}
|
|
101
227
|
]
|
|
102
228
|
}, server_api_1.SKVersion.v1);
|
|
229
|
+
server.debug(`[WRITE TRACE] handleMessage success path=${path}`);
|
|
103
230
|
return completed(200);
|
|
104
231
|
}
|
|
105
232
|
catch (error) {
|
|
106
233
|
const message = error?.message ?? 'Unable to write display path';
|
|
234
|
+
server.error(`[WRITE TRACE] handleMessage failure path=${path} message=${message}`);
|
|
107
235
|
return completed(400, message);
|
|
108
236
|
}
|
|
109
237
|
}
|
|
110
238
|
function handleSetDisplay(value) {
|
|
239
|
+
server.debug(`[COMMAND TRACE] handleSetDisplay payload=${JSON.stringify(value)}`);
|
|
111
240
|
const command = value;
|
|
112
241
|
if (!command || typeof command !== 'object') {
|
|
113
242
|
return completed(400, 'Command payload is required');
|
|
@@ -122,6 +251,7 @@ const start = (server) => {
|
|
|
122
251
|
return applyDisplayWrite(command.displayId, null, displayValue);
|
|
123
252
|
}
|
|
124
253
|
function handleScreenWrite(value, suffix) {
|
|
254
|
+
server.debug(`[COMMAND TRACE] handleScreenWrite suffix=${suffix} payload=${JSON.stringify(value)}`);
|
|
125
255
|
const command = value;
|
|
126
256
|
if (!command || typeof command !== 'object') {
|
|
127
257
|
return completed(400, 'Command payload is required');
|
|
@@ -136,38 +266,436 @@ const start = (server) => {
|
|
|
136
266
|
return applyDisplayWrite(command.displayId, suffix, screenIdxValue);
|
|
137
267
|
}
|
|
138
268
|
function sendActionAsRest(res, result) {
|
|
269
|
+
server.debug(`[REST TRACE] sendActionAsRest statusCode=${result.statusCode} message=${result.message ?? ''}`);
|
|
139
270
|
if (result.statusCode === 200) {
|
|
140
271
|
return res.status(200).json({ state: 'SUCCESS', statusCode: 200 });
|
|
141
272
|
}
|
|
142
273
|
return sendFail(res, result.statusCode || 400, result.message || 'Command failed');
|
|
143
274
|
}
|
|
275
|
+
function stopSeriesCapture() {
|
|
276
|
+
streamUnsubscribes.forEach(unsub => {
|
|
277
|
+
try {
|
|
278
|
+
unsub();
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// ignore unsubscribe failures
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
streamUnsubscribes = [];
|
|
285
|
+
}
|
|
286
|
+
function toIsoString(value) {
|
|
287
|
+
if (typeof value === 'string' && value.trim()) {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
if (value instanceof Date) {
|
|
291
|
+
return value.toISOString();
|
|
292
|
+
}
|
|
293
|
+
if (value && typeof value === 'object' && typeof value.toString === 'function') {
|
|
294
|
+
const serialized = value.toString();
|
|
295
|
+
return serialized && serialized !== '[object Object]' ? serialized : undefined;
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
function toDurationString(value) {
|
|
300
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
if (typeof value === 'string' && value.trim()) {
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
if (value && typeof value === 'object' && typeof value.toString === 'function') {
|
|
307
|
+
const serialized = value.toString();
|
|
308
|
+
return serialized && serialized !== '[object Object]' ? serialized : undefined;
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
function normalizeHistoryMethod(method) {
|
|
313
|
+
const raw = String(method ?? 'avg').trim().toLowerCase();
|
|
314
|
+
if (raw === 'average') {
|
|
315
|
+
return 'avg';
|
|
316
|
+
}
|
|
317
|
+
if (raw === 'min' || raw === 'max' || raw === 'sma' || raw === 'ema' || raw === 'avg') {
|
|
318
|
+
return raw;
|
|
319
|
+
}
|
|
320
|
+
return 'avg';
|
|
321
|
+
}
|
|
322
|
+
function normalizeHistoryPath(path) {
|
|
323
|
+
const trimmed = typeof path === 'string' ? path.trim() : '';
|
|
324
|
+
if (!trimmed) {
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
if (trimmed.startsWith('vessels.self.')) {
|
|
328
|
+
return trimmed.slice('vessels.self.'.length);
|
|
329
|
+
}
|
|
330
|
+
if (trimmed.startsWith('self.')) {
|
|
331
|
+
return trimmed.slice('self.'.length);
|
|
332
|
+
}
|
|
333
|
+
return trimmed;
|
|
334
|
+
}
|
|
335
|
+
function buildPathsFromPathSpecs(pathSpecs) {
|
|
336
|
+
if (!Array.isArray(pathSpecs)) {
|
|
337
|
+
return '';
|
|
338
|
+
}
|
|
339
|
+
const specs = pathSpecs;
|
|
340
|
+
const encoded = specs
|
|
341
|
+
.map(spec => {
|
|
342
|
+
const path = normalizeHistoryPath(spec.path);
|
|
343
|
+
if (!path) {
|
|
344
|
+
return '';
|
|
345
|
+
}
|
|
346
|
+
const method = normalizeHistoryMethod(spec.aggregate);
|
|
347
|
+
const params = Array.isArray(spec.parameter)
|
|
348
|
+
? spec.parameter.map(item => String(item).trim()).filter(Boolean)
|
|
349
|
+
: [];
|
|
350
|
+
return [path, method, ...params].join(':');
|
|
351
|
+
})
|
|
352
|
+
.filter(Boolean);
|
|
353
|
+
return encoded.join(',');
|
|
354
|
+
}
|
|
355
|
+
function buildHistoryQueryFromValuesRequest(query) {
|
|
356
|
+
const from = toIsoString(query?.from);
|
|
357
|
+
const to = toIsoString(query?.to);
|
|
358
|
+
const duration = toDurationString(query?.duration);
|
|
359
|
+
const paths = buildPathsFromPathSpecs(query?.pathSpecs);
|
|
360
|
+
return {
|
|
361
|
+
paths,
|
|
362
|
+
context: typeof query?.context === 'string' ? query.context : undefined,
|
|
363
|
+
from,
|
|
364
|
+
to,
|
|
365
|
+
duration,
|
|
366
|
+
resolution: typeof query?.resolution === 'number' || typeof query?.resolution === 'string' ? query.resolution : undefined
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function buildHistoryQueryFromRangeRequest(query) {
|
|
370
|
+
return {
|
|
371
|
+
from: toIsoString(query?.from),
|
|
372
|
+
to: toIsoString(query?.to),
|
|
373
|
+
duration: toDurationString(query?.duration)
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
async function resolveHistoryPaths(query) {
|
|
377
|
+
await waitForDuckDbInitialization();
|
|
378
|
+
if (!storageService.isDuckDbParquetReady()) {
|
|
379
|
+
throw new Error(getDuckDbUnavailableMessage());
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
await storageService.flush();
|
|
383
|
+
}
|
|
384
|
+
catch (flushError) {
|
|
385
|
+
server.error(`[SERIES STORAGE] pre-paths flush failed: ${String(flushError.message || flushError)}`);
|
|
386
|
+
}
|
|
387
|
+
return storageService.getStoredPaths({
|
|
388
|
+
...(query ?? {})
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
async function resolveHistoryContexts(query) {
|
|
392
|
+
await waitForDuckDbInitialization();
|
|
393
|
+
if (!storageService.isDuckDbParquetReady()) {
|
|
394
|
+
throw new Error(getDuckDbUnavailableMessage());
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
await storageService.flush();
|
|
398
|
+
}
|
|
399
|
+
catch (flushError) {
|
|
400
|
+
server.error(`[SERIES STORAGE] pre-contexts flush failed: ${String(flushError.message || flushError)}`);
|
|
401
|
+
}
|
|
402
|
+
return storageService.getStoredContexts({
|
|
403
|
+
...(query ?? {})
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async function resolveHistoryValues(query) {
|
|
407
|
+
await waitForDuckDbInitialization();
|
|
408
|
+
if (!storageService.isDuckDbParquetReady()) {
|
|
409
|
+
throw new Error(getDuckDbUnavailableMessage());
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
await storageService.flush();
|
|
413
|
+
}
|
|
414
|
+
catch (flushError) {
|
|
415
|
+
server.error(`[SERIES STORAGE] pre-query flush failed: ${String(flushError.message || flushError)}`);
|
|
416
|
+
}
|
|
417
|
+
const values = await storageService.getValues({
|
|
418
|
+
...query
|
|
419
|
+
});
|
|
420
|
+
if (!values) {
|
|
421
|
+
throw new Error('DuckDB storage did not return history values.');
|
|
422
|
+
}
|
|
423
|
+
return values;
|
|
424
|
+
}
|
|
425
|
+
function registerHistoryProvider() {
|
|
426
|
+
historyApiProviderRegistered = false;
|
|
427
|
+
if (!isHistoryApiProviderEnabled()) {
|
|
428
|
+
server.debug('[HISTORY PROVIDER] Registration disabled by plugin configuration');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const host = server;
|
|
432
|
+
const apiProvider = {
|
|
433
|
+
getValues: async (query) => {
|
|
434
|
+
const resolved = await resolveHistoryValues(buildHistoryQueryFromValuesRequest(query));
|
|
435
|
+
return {
|
|
436
|
+
...resolved,
|
|
437
|
+
values: resolved.values.map(valueSpec => ({
|
|
438
|
+
path: valueSpec.path,
|
|
439
|
+
method: valueSpec.method === 'avg' ? 'average' : valueSpec.method
|
|
440
|
+
}))
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
getPaths: (query) => resolveHistoryPaths(buildHistoryQueryFromRangeRequest(query)),
|
|
444
|
+
getContexts: (query) => resolveHistoryContexts(buildHistoryQueryFromRangeRequest(query))
|
|
445
|
+
};
|
|
446
|
+
const registry = host.history && typeof host.history.registerHistoryApiProvider === 'function'
|
|
447
|
+
? host.history
|
|
448
|
+
: (typeof host.registerHistoryApiProvider === 'function'
|
|
449
|
+
? {
|
|
450
|
+
registerHistoryApiProvider: host.registerHistoryApiProvider.bind(host),
|
|
451
|
+
unregisterHistoryApiProvider: typeof host.unregisterHistoryApiProvider === 'function'
|
|
452
|
+
? host.unregisterHistoryApiProvider.bind(host)
|
|
453
|
+
: undefined
|
|
454
|
+
}
|
|
455
|
+
: null);
|
|
456
|
+
if (registry && typeof registry.registerHistoryApiProvider === 'function') {
|
|
457
|
+
registry.registerHistoryApiProvider(apiProvider);
|
|
458
|
+
historyApiProviderRegistered = true;
|
|
459
|
+
if (typeof registry.unregisterHistoryApiProvider === 'function') {
|
|
460
|
+
historyApiRegistry = { unregisterHistoryApiProvider: registry.unregisterHistoryApiProvider.bind(registry) };
|
|
461
|
+
}
|
|
462
|
+
server.debug('[HISTORY PROVIDER] Registered KIP as History API provider');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
server.debug('[HISTORY PROVIDER] Registration requested but no compatible registration API was found on server host');
|
|
466
|
+
}
|
|
467
|
+
function rebuildSeriesCaptureSubscriptions() {
|
|
468
|
+
stopSeriesCapture();
|
|
469
|
+
if (!isHistorySeriesServiceEnabled()) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const streamBundle = server.streambundle;
|
|
473
|
+
if (!streamBundle || typeof streamBundle.getBus !== 'function') {
|
|
474
|
+
// server.debug('[SERIES CAPTURE] streambundle.getBus not available; capture disabled');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const subscriptionCandidates = new Map();
|
|
478
|
+
const addCandidate = (path, allSelfContext) => {
|
|
479
|
+
const normalized = typeof path === 'string' ? path.trim() : '';
|
|
480
|
+
if (!normalized) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const existing = subscriptionCandidates.get(normalized);
|
|
484
|
+
if (!existing) {
|
|
485
|
+
subscriptionCandidates.set(normalized, { allSelfContext });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// If any series for this path requires non-self context, force generic bus subscription.
|
|
489
|
+
existing.allSelfContext = existing.allSelfContext && allSelfContext;
|
|
490
|
+
};
|
|
491
|
+
historySeries.listSeries().filter(series => series.enabled !== false).forEach(series => {
|
|
492
|
+
const allSelfContext = (series.context ?? 'vessels.self') === 'vessels.self';
|
|
493
|
+
addCandidate(series.path, allSelfContext);
|
|
494
|
+
// Workaround: subscribe to immediate parent path so object deltas (e.g. navigation.attitude)
|
|
495
|
+
// are captured and flattened into leaf numeric paths (e.g. navigation.attitude.pitch).
|
|
496
|
+
// Remove this fallback when KIP adds first-class object-path capture support.
|
|
497
|
+
const parentIdx = series.path.lastIndexOf('.');
|
|
498
|
+
if (parentIdx > 0) {
|
|
499
|
+
addCandidate(series.path.slice(0, parentIdx), allSelfContext);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
const candidates = Array.from(subscriptionCandidates.entries()).map(([path, meta]) => ({
|
|
503
|
+
path,
|
|
504
|
+
allSelfContext: meta.allSelfContext
|
|
505
|
+
}));
|
|
506
|
+
candidates.forEach(candidate => {
|
|
507
|
+
try {
|
|
508
|
+
const bus = candidate.allSelfContext && typeof streamBundle.getSelfBus === 'function'
|
|
509
|
+
? streamBundle.getSelfBus(candidate.path)
|
|
510
|
+
: streamBundle.getBus(candidate.path);
|
|
511
|
+
if (!bus || typeof bus.onValue !== 'function') {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const unsubscribe = bus.onValue((sample) => {
|
|
515
|
+
try {
|
|
516
|
+
const count = historySeries.recordFromSignalKSample(sample);
|
|
517
|
+
if (count > 0) {
|
|
518
|
+
// server.debug(`[SERIES CAPTURE] path=${candidate.path} recorded=${count}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
server.error(`[SERIES CAPTURE] failed to record sample path=${candidate.path}: ${String(error.message || error)}`);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
if (typeof unsubscribe === 'function') {
|
|
526
|
+
streamUnsubscribes.push(unsubscribe);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
server.error(`[SERIES CAPTURE] failed to subscribe path=${candidate.path}: ${String(error.message || error)}`);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
// server.debug(`[SERIES CAPTURE] activePathSubscriptions=${candidates.length}`);
|
|
534
|
+
}
|
|
535
|
+
function startStorageFlushTimer(intervalMs) {
|
|
536
|
+
if (storageFlushTimer) {
|
|
537
|
+
clearInterval(storageFlushTimer);
|
|
538
|
+
storageFlushTimer = null;
|
|
539
|
+
}
|
|
540
|
+
if (!storageService.isDuckDbParquetEnabled()) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
storageFlushTimer = setInterval(() => {
|
|
544
|
+
void storageService.flush()
|
|
545
|
+
.then(result => {
|
|
546
|
+
if (result.inserted > 0 || result.exported > 0) {
|
|
547
|
+
server.debug(`[SERIES STORAGE] flushed inserted=${result.inserted} exported=${result.exported}`);
|
|
548
|
+
}
|
|
549
|
+
})
|
|
550
|
+
.catch(error => {
|
|
551
|
+
server.error(`[SERIES STORAGE] flush failed: ${String(error.message || error)}`);
|
|
552
|
+
});
|
|
553
|
+
}, intervalMs);
|
|
554
|
+
storageFlushTimer.unref?.();
|
|
555
|
+
}
|
|
144
556
|
const plugin = {
|
|
145
557
|
id: 'kip',
|
|
146
558
|
name: 'KIP',
|
|
147
559
|
description: 'KIP server plugin',
|
|
148
560
|
start: (settings) => {
|
|
149
561
|
server.debug(`Starting plugin with settings: ${JSON.stringify(settings)}`);
|
|
562
|
+
const modeConfig = resolveHistoryModeConfig(settings);
|
|
563
|
+
historySeriesServiceEnabled = modeConfig.historySeriesServiceEnabled;
|
|
564
|
+
registerAsHistoryApiProvider = modeConfig.registerAsHistoryApiProvider;
|
|
565
|
+
logOperationalMode('start-configured');
|
|
566
|
+
storageService.setLogger({
|
|
567
|
+
debug: (msg) => server.debug(msg),
|
|
568
|
+
error: (msg) => server.error(msg)
|
|
569
|
+
});
|
|
570
|
+
const storageConfig = storageService.configure(settings);
|
|
571
|
+
server.debug(`[SERIES STORAGE] engine=${storageConfig.engine} db=${storageConfig.databaseFile} parquetDir=${storageConfig.parquetDirectory} flushMs=${storageConfig.flushIntervalMs}`);
|
|
572
|
+
historySeries.setSampleSink(sample => {
|
|
573
|
+
storageService.enqueueSample(sample);
|
|
574
|
+
});
|
|
575
|
+
duckDbInitializationPromise = storageService.initialize();
|
|
576
|
+
void duckDbInitializationPromise.then((ready) => {
|
|
577
|
+
server.debug(`[SERIES STORAGE] duckdbReady=${ready}`);
|
|
578
|
+
if (ready && storageService.isDuckDbParquetEnabled()) {
|
|
579
|
+
if (isHistorySeriesServiceEnabled()) {
|
|
580
|
+
void storageService.getSeriesDefinitions()
|
|
581
|
+
.then((storedSeries) => {
|
|
582
|
+
if (storedSeries.length > 0) {
|
|
583
|
+
historySeries.reconcileSeries(storedSeries);
|
|
584
|
+
rebuildSeriesCaptureSubscriptions();
|
|
585
|
+
}
|
|
586
|
+
startStorageFlushTimer(storageConfig.flushIntervalMs);
|
|
587
|
+
logOperationalMode('duckdb-ready');
|
|
588
|
+
server.setPluginStatus(`KIP plugin started with DuckDB/Parquet history storage. Loaded ${storedSeries.length} persisted series. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
|
|
589
|
+
})
|
|
590
|
+
.catch((loadError) => {
|
|
591
|
+
server.error(`[SERIES STORAGE] failed to load persisted series: ${String(loadError.message || loadError)}`);
|
|
592
|
+
startStorageFlushTimer(storageConfig.flushIntervalMs);
|
|
593
|
+
logOperationalMode('duckdb-ready-series-load-failed');
|
|
594
|
+
server.setPluginStatus(`KIP plugin started with DuckDB/Parquet history storage. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
historySeries.reconcileSeries([]);
|
|
599
|
+
stopSeriesCapture();
|
|
600
|
+
startStorageFlushTimer(storageConfig.flushIntervalMs);
|
|
601
|
+
logOperationalMode('duckdb-ready-series-disabled');
|
|
602
|
+
server.setPluginStatus(`KIP plugin started with history-series service disabled. historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (storageFlushTimer) {
|
|
607
|
+
clearInterval(storageFlushTimer);
|
|
608
|
+
storageFlushTimer = null;
|
|
609
|
+
}
|
|
610
|
+
const initError = storageService.getLastInitError();
|
|
611
|
+
if (initError) {
|
|
612
|
+
server.setPluginError(`DuckDB unavailable. ${initError}`);
|
|
613
|
+
logOperationalMode('duckdb-unavailable');
|
|
614
|
+
server.setPluginStatus(`KIP plugin started with DuckDB unavailable. historySeriesServiceEnabled=${isHistorySeriesServiceEnabled()} historyApiProviderEnabled=${isHistoryApiProviderEnabled()} historyApiProviderRegistered=${historyApiProviderRegistered}`);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
if (retentionSweepTimer) {
|
|
618
|
+
clearInterval(retentionSweepTimer);
|
|
619
|
+
}
|
|
620
|
+
retentionSweepTimer = setInterval(() => {
|
|
621
|
+
try {
|
|
622
|
+
if (storageService.isDuckDbParquetReady()) {
|
|
623
|
+
const lifecycleToken = storageService.getLifecycleToken();
|
|
624
|
+
void storageService.pruneExpiredSamples(Date.now(), lifecycleToken)
|
|
625
|
+
.then(removedPersistedRows => {
|
|
626
|
+
if (removedPersistedRows > 0) {
|
|
627
|
+
server.debug(`[SERIES RETENTION] duckdbPrune removedRows=${removedPersistedRows}`);
|
|
628
|
+
}
|
|
629
|
+
return storageService.pruneOrphanedSamples(lifecycleToken)
|
|
630
|
+
.then(removedOrphanRows => {
|
|
631
|
+
if (removedOrphanRows > 0) {
|
|
632
|
+
server.debug(`[SERIES RETENTION] duckdbOrphanPrune removedRows=${removedOrphanRows}`);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
})
|
|
636
|
+
.catch(error => {
|
|
637
|
+
server.error(`[SERIES RETENTION] duckdbPrune failed: ${String(error.message || error)}`);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
server.error(`[SERIES RETENTION] sweep failed: ${String(error.message || error)}`);
|
|
643
|
+
}
|
|
644
|
+
}, 60 * 60_000);
|
|
645
|
+
retentionSweepTimer.unref?.();
|
|
646
|
+
rebuildSeriesCaptureSubscriptions();
|
|
150
647
|
if (server.registerPutHandler) {
|
|
648
|
+
server.debug(`[COMMAND TRACE] Registering PUT handlers under context=${PUT_CONTEXT}`);
|
|
151
649
|
server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_DISPLAY, (context, path, value) => {
|
|
650
|
+
server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.SET_DISPLAY}`);
|
|
152
651
|
void context;
|
|
153
652
|
void path;
|
|
154
653
|
return handleSetDisplay(value);
|
|
155
654
|
}, plugin.id);
|
|
156
655
|
server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.SET_SCREEN_INDEX, (context, path, value) => {
|
|
656
|
+
server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.SET_SCREEN_INDEX}`);
|
|
157
657
|
void context;
|
|
158
658
|
void path;
|
|
159
659
|
return handleScreenWrite(value, 'screenIndex');
|
|
160
660
|
}, plugin.id);
|
|
161
661
|
server.registerPutHandler(PUT_CONTEXT, COMMAND_PATHS.REQUEST_ACTIVE_SCREEN, (context, path, value) => {
|
|
662
|
+
server.debug(`[COMMAND TRACE] PUT handler hit path=${String(path)} context=${String(context)} command=${COMMAND_PATHS.REQUEST_ACTIVE_SCREEN}`);
|
|
162
663
|
void context;
|
|
163
664
|
void path;
|
|
164
665
|
return handleScreenWrite(value, 'activeScreen');
|
|
165
666
|
}, plugin.id);
|
|
166
667
|
}
|
|
668
|
+
registerHistoryProvider();
|
|
669
|
+
logOperationalMode('post-provider-registration');
|
|
167
670
|
server.setPluginStatus(`Starting...`);
|
|
168
671
|
},
|
|
169
672
|
stop: () => {
|
|
170
673
|
server.debug(`Stopping plugin`);
|
|
674
|
+
stopSeriesCapture();
|
|
675
|
+
if (retentionSweepTimer) {
|
|
676
|
+
clearInterval(retentionSweepTimer);
|
|
677
|
+
retentionSweepTimer = null;
|
|
678
|
+
}
|
|
679
|
+
if (storageFlushTimer) {
|
|
680
|
+
clearInterval(storageFlushTimer);
|
|
681
|
+
storageFlushTimer = null;
|
|
682
|
+
}
|
|
683
|
+
const storageLifecycleToken = storageService.getLifecycleToken();
|
|
684
|
+
void storageService.flush(storageLifecycleToken)
|
|
685
|
+
.catch(() => undefined)
|
|
686
|
+
.then(() => storageService.close(storageLifecycleToken))
|
|
687
|
+
.catch(() => undefined);
|
|
688
|
+
if (historyApiRegistry) {
|
|
689
|
+
try {
|
|
690
|
+
historyApiRegistry.unregisterHistoryApiProvider();
|
|
691
|
+
server.debug('[HISTORY PROVIDER] Unregistered KIP History API provider');
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
server.error(`[HISTORY PROVIDER] unregister failed: ${String(error.message || error)}`);
|
|
695
|
+
}
|
|
696
|
+
historyApiRegistry = null;
|
|
697
|
+
}
|
|
698
|
+
duckDbInitializationPromise = null;
|
|
171
699
|
const msg = 'Stopped.';
|
|
172
700
|
server.setPluginStatus(msg);
|
|
173
701
|
},
|
|
@@ -176,6 +704,7 @@ const start = (server) => {
|
|
|
176
704
|
server.debug(`Registering plugin routes: ${API_PATHS.DISPLAYS}, ${API_PATHS.INSTANCE}, ${API_PATHS.SCREEN_INDEX}, ${API_PATHS.ACTIVATE_SCREEN}`);
|
|
177
705
|
// Validate/normalize :displayId where present
|
|
178
706
|
router.param('displayId', (req, res, next, displayId) => {
|
|
707
|
+
logAuthTrace(req, 'router.param:displayId:entry');
|
|
179
708
|
if (displayId == null)
|
|
180
709
|
return sendFail(res, 400, 'Missing displayId parameter');
|
|
181
710
|
try {
|
|
@@ -207,13 +736,16 @@ const start = (server) => {
|
|
|
207
736
|
return sendFail(res, 400, 'Invalid displayId format');
|
|
208
737
|
}
|
|
209
738
|
req.displayId = id;
|
|
739
|
+
server.debug(`[AUTH TRACE] router.param:displayId:normalized displayId=${id}`);
|
|
210
740
|
next();
|
|
211
741
|
}
|
|
212
742
|
catch {
|
|
743
|
+
server.error(`[AUTH TRACE] router.param:displayId:failed rawDisplayId=${String(displayId)}`);
|
|
213
744
|
return sendFail(res, 400, 'Missing or invalid displayId parameter');
|
|
214
745
|
}
|
|
215
746
|
});
|
|
216
747
|
router.put(`${API_PATHS.INSTANCE}`, async (req, res) => {
|
|
748
|
+
logAuthTrace(req, 'route:PUT:INSTANCE:entry');
|
|
217
749
|
server.debug(`** PUT ${API_PATHS.INSTANCE}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
|
|
218
750
|
try {
|
|
219
751
|
const displayId = req.displayId;
|
|
@@ -231,6 +763,7 @@ const start = (server) => {
|
|
|
231
763
|
}
|
|
232
764
|
});
|
|
233
765
|
router.put(`${API_PATHS.SCREEN_INDEX}`, async (req, res) => {
|
|
766
|
+
logAuthTrace(req, 'route:PUT:SCREEN_INDEX:entry');
|
|
234
767
|
server.debug(`** PUT ${API_PATHS.SCREEN_INDEX}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
|
|
235
768
|
try {
|
|
236
769
|
const displayId = req.displayId;
|
|
@@ -251,6 +784,7 @@ const start = (server) => {
|
|
|
251
784
|
}
|
|
252
785
|
});
|
|
253
786
|
router.put(`${API_PATHS.ACTIVATE_SCREEN}`, async (req, res) => {
|
|
787
|
+
logAuthTrace(req, 'route:PUT:ACTIVATE_SCREEN:entry');
|
|
254
788
|
server.debug(`** PUT ${API_PATHS.ACTIVATE_SCREEN}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
|
|
255
789
|
try {
|
|
256
790
|
const displayId = req.displayId;
|
|
@@ -300,11 +834,11 @@ const start = (server) => {
|
|
|
300
834
|
return sendFail(res, 400, 'Missing displayId parameter');
|
|
301
835
|
}
|
|
302
836
|
const node = getDisplaySelfPath(displayId);
|
|
303
|
-
|
|
304
|
-
const screens = node?.value?.screens ?? null;
|
|
305
|
-
if (screens === undefined) {
|
|
837
|
+
if (node === undefined) {
|
|
306
838
|
return sendFail(res, 404, `Display ${displayId} not found`);
|
|
307
839
|
}
|
|
840
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
841
|
+
const screens = node?.value?.screens ?? null;
|
|
308
842
|
return sendOk(res, screens);
|
|
309
843
|
}
|
|
310
844
|
catch (error) {
|
|
@@ -320,11 +854,11 @@ const start = (server) => {
|
|
|
320
854
|
return sendFail(res, 400, 'Missing displayId parameter');
|
|
321
855
|
}
|
|
322
856
|
const node = getDisplaySelfPath(displayId, 'screenIndex');
|
|
323
|
-
|
|
324
|
-
const idx = node?.value ?? null;
|
|
325
|
-
if (idx === undefined) {
|
|
857
|
+
if (node === undefined) {
|
|
326
858
|
return sendFail(res, 404, `Active screen for display Id ${displayId} not found in path`);
|
|
327
859
|
}
|
|
860
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
861
|
+
const idx = node?.value ?? null;
|
|
328
862
|
return sendOk(res, idx);
|
|
329
863
|
}
|
|
330
864
|
catch (error) {
|
|
@@ -340,11 +874,11 @@ const start = (server) => {
|
|
|
340
874
|
return sendFail(res, 400, 'Missing displayId parameter');
|
|
341
875
|
}
|
|
342
876
|
const node = getDisplaySelfPath(displayId, 'activeScreen');
|
|
343
|
-
|
|
344
|
-
const idx = node?.value ?? null;
|
|
345
|
-
if (idx === undefined) {
|
|
877
|
+
if (node === undefined) {
|
|
346
878
|
return sendFail(res, 404, `Change display screen Id ${displayId} not found in path`);
|
|
347
879
|
}
|
|
880
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
881
|
+
const idx = node?.value ?? null;
|
|
348
882
|
return sendOk(res, idx);
|
|
349
883
|
}
|
|
350
884
|
catch (error) {
|
|
@@ -352,6 +886,127 @@ const start = (server) => {
|
|
|
352
886
|
return sendFail(res, 400, error.message);
|
|
353
887
|
}
|
|
354
888
|
});
|
|
889
|
+
router.get(API_PATHS.SERIES, async (req, res) => {
|
|
890
|
+
server.debug(`*** GET SERIES ${API_PATHS.SERIES}. Params: ${JSON.stringify(req.params)}`);
|
|
891
|
+
try {
|
|
892
|
+
if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (!(await ensureDuckDbReadyForRequest(res))) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
return sendOk(res, historySeries.listSeries());
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
server.error(`Error reading series: ${String(error.message || error)}`);
|
|
902
|
+
const mapped = getRouteError(error, 'Failed to read series');
|
|
903
|
+
return sendFail(res, mapped.statusCode, mapped.message);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
router.put(API_PATHS.SERIES_INSTANCE, async (req, res) => {
|
|
907
|
+
server.debug(`** PUT ${API_PATHS.SERIES_INSTANCE}. Params: ${JSON.stringify(req.params)} Body: ${JSON.stringify(req.body)}`);
|
|
908
|
+
try {
|
|
909
|
+
if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (!(await ensureDuckDbReadyForRequest(res))) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const seriesId = String(req.params.seriesId ?? '').trim();
|
|
916
|
+
if (!seriesId) {
|
|
917
|
+
return sendFail(res, 400, 'Missing seriesId parameter');
|
|
918
|
+
}
|
|
919
|
+
const payload = (req.body ?? {});
|
|
920
|
+
const previous = findSeriesById(seriesId);
|
|
921
|
+
const next = historySeries.upsertSeries({
|
|
922
|
+
...payload,
|
|
923
|
+
seriesId,
|
|
924
|
+
datasetUuid: String(payload.datasetUuid ?? seriesId)
|
|
925
|
+
});
|
|
926
|
+
try {
|
|
927
|
+
await storageService.upsertSeriesDefinition(next);
|
|
928
|
+
}
|
|
929
|
+
catch (storageError) {
|
|
930
|
+
if (previous) {
|
|
931
|
+
historySeries.upsertSeries(previous);
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
historySeries.deleteSeries(seriesId);
|
|
935
|
+
}
|
|
936
|
+
throw storageError;
|
|
937
|
+
}
|
|
938
|
+
rebuildSeriesCaptureSubscriptions();
|
|
939
|
+
return sendOk(res, next);
|
|
940
|
+
}
|
|
941
|
+
catch (error) {
|
|
942
|
+
server.error(`Error writing series: ${String(error.message || error)}`);
|
|
943
|
+
const mapped = getRouteError(error, 'Failed to write series');
|
|
944
|
+
return sendFail(res, mapped.statusCode, mapped.message);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
router.delete(API_PATHS.SERIES_INSTANCE, async (req, res) => {
|
|
948
|
+
server.debug(`** DELETE ${API_PATHS.SERIES_INSTANCE}. Params: ${JSON.stringify(req.params)}`);
|
|
949
|
+
try {
|
|
950
|
+
if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (!(await ensureDuckDbReadyForRequest(res))) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const seriesId = String(req.params.seriesId ?? '').trim();
|
|
957
|
+
if (!seriesId) {
|
|
958
|
+
return sendFail(res, 400, 'Missing seriesId parameter');
|
|
959
|
+
}
|
|
960
|
+
const previous = findSeriesById(seriesId);
|
|
961
|
+
if (!previous) {
|
|
962
|
+
return sendFail(res, 404, `Series ${seriesId} not found`);
|
|
963
|
+
}
|
|
964
|
+
await storageService.deleteSeriesDefinition(seriesId);
|
|
965
|
+
historySeries.deleteSeries(seriesId);
|
|
966
|
+
rebuildSeriesCaptureSubscriptions();
|
|
967
|
+
return sendOk(res, { state: 'SUCCESS', statusCode: 200 });
|
|
968
|
+
}
|
|
969
|
+
catch (error) {
|
|
970
|
+
server.error(`Error deleting series: ${String(error.message || error)}`);
|
|
971
|
+
const mapped = getRouteError(error, 'Failed to delete series');
|
|
972
|
+
return sendFail(res, mapped.statusCode, mapped.message);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
router.post(API_PATHS.SERIES_RECONCILE, async (req, res) => {
|
|
976
|
+
server.debug(`** POST ${API_PATHS.SERIES_RECONCILE}. Body: ${JSON.stringify(req.body)}`);
|
|
977
|
+
try {
|
|
978
|
+
if (!ensureHistorySeriesServiceEnabledForRequest(res)) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (!(await ensureDuckDbReadyForRequest(res))) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const payload = req.body;
|
|
985
|
+
if (!Array.isArray(payload)) {
|
|
986
|
+
return sendFail(res, 400, 'Body must be an array of series definitions');
|
|
987
|
+
}
|
|
988
|
+
const simulated = new history_series_service_1.HistorySeriesService(() => Date.now(), false);
|
|
989
|
+
historySeries.listSeries().forEach(series => {
|
|
990
|
+
simulated.upsertSeries(series);
|
|
991
|
+
});
|
|
992
|
+
const scopedPayload = payload.map(series => ({
|
|
993
|
+
...series
|
|
994
|
+
}));
|
|
995
|
+
const result = simulated.reconcileSeries(scopedPayload);
|
|
996
|
+
const nextSeries = simulated.listSeries();
|
|
997
|
+
await storageService.replaceSeriesDefinitions(nextSeries);
|
|
998
|
+
const seriesOutsideScope = historySeries.listSeries();
|
|
999
|
+
historySeries.reconcileSeries([...seriesOutsideScope, ...nextSeries]);
|
|
1000
|
+
server.debug(`[SERIES RECONCILE] created=${result.created} updated=${result.updated} deleted=${result.deleted} total=${result.total}`);
|
|
1001
|
+
rebuildSeriesCaptureSubscriptions();
|
|
1002
|
+
return sendOk(res, result);
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
server.error(`Error reconciling series: ${String(error.message || error)}`);
|
|
1006
|
+
const mapped = getRouteError(error, 'Failed to reconcile series');
|
|
1007
|
+
return sendFail(res, mapped.statusCode, mapped.message);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
355
1010
|
// List all registered routes for debugging
|
|
356
1011
|
if (router.stack) {
|
|
357
1012
|
router.stack.forEach((layer) => {
|
|
@@ -360,7 +1015,7 @@ const start = (server) => {
|
|
|
360
1015
|
}
|
|
361
1016
|
});
|
|
362
1017
|
}
|
|
363
|
-
server.setPluginStatus(`Providing remote display screen control`);
|
|
1018
|
+
server.setPluginStatus(`Providing remote display screen control and history series API`);
|
|
364
1019
|
},
|
|
365
1020
|
getOpenApi: () => mutableOpenApi
|
|
366
1021
|
};
|