signalk-edge-link 2.2.0 → 2.3.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/lib/delta-sanitizer.js +86 -0
- package/lib/instance.js +284 -8
- package/lib/metadata.js +467 -0
- package/lib/metrics.js +22 -1
- package/lib/packet.js +51 -14
- package/lib/pathDictionary.js +20 -1
- package/lib/pipeline-v2-client.js +177 -12
- package/lib/pipeline-v2-server.js +236 -2
- package/lib/pipeline.js +221 -1
- package/lib/prometheus.js +11 -0
- package/lib/routes/config-validation.js +49 -0
- package/lib/routes/metrics.js +1 -0
- package/lib/routes.js +25 -4
- package/package.json +1 -1
- package/public/{982.b207a377ed6542e2fb4a.js → 982.63949a2b2f6c5854e034.js} +2 -2
- package/public/982.63949a2b2f6c5854e034.js.map +1 -0
- package/public/index.html +1 -1
- package/public/main.0b6f5e3267731da945f0.js +2 -0
- package/public/main.0b6f5e3267731da945f0.js.map +1 -0
- package/public/main.js +467 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.b207a377ed6542e2fb4a.js.map +0 -1
- package/public/main.f1780db6593b0c07a48c.js +0 -2
- package/public/main.f1780db6593b0c07a48c.js.map +0 -1
package/lib/pipeline.js
CHANGED
|
@@ -35,8 +35,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
const msgpack = __importStar(require("@msgpack/msgpack"));
|
|
36
36
|
const crypto_1 = require("./crypto");
|
|
37
37
|
const pathDictionary_1 = require("./pathDictionary");
|
|
38
|
+
const delta_sanitizer_1 = require("./delta-sanitizer");
|
|
38
39
|
const pipeline_utils_1 = require("./pipeline-utils");
|
|
39
40
|
const constants_1 = require("./constants");
|
|
41
|
+
const metadata_1 = require("./metadata");
|
|
42
|
+
/** Leading magic that distinguishes v1 meta payloads from v1 deltas, placed
|
|
43
|
+
* inside the encrypted plaintext so existing v1 receivers (which do not
|
|
44
|
+
* recognise it) simply reject the packet rather than misinterpreting it. */
|
|
45
|
+
const V1_META_MAGIC = Buffer.from("SKM1", "ascii");
|
|
46
|
+
/** Threshold for v1 sender-restart detection — see the v2 server's
|
|
47
|
+
* META_RESTART_THRESHOLD comment. envSeq=0 is treated as a restart only when
|
|
48
|
+
* the last accepted seq has moved beyond this small reorder window. */
|
|
49
|
+
const META_RESTART_THRESHOLD_V1 = 8;
|
|
40
50
|
/**
|
|
41
51
|
* Creates the data processing pipeline (compress, encrypt, send / receive, decrypt, decompress).
|
|
42
52
|
* @param app - SignalK app object (for logging)
|
|
@@ -47,6 +57,12 @@ const constants_1 = require("./constants");
|
|
|
47
57
|
function createPipeline(app, state, metricsApi) {
|
|
48
58
|
const { metrics, recordError, trackPathStats } = metricsApi;
|
|
49
59
|
const setStatus = app.setPluginStatus || app.setProviderStatus || (() => { });
|
|
60
|
+
let metaEnvelopeSeqV1 = 0;
|
|
61
|
+
// Last accepted inner-envelope seq on the receive side. v1 has no
|
|
62
|
+
// per-session concept (one socket per pipeline instance), so a single
|
|
63
|
+
// closure variable is sufficient. Used to drop stale/duplicate envelopes
|
|
64
|
+
// that UDP reorders or replays.
|
|
65
|
+
let lastIngestedMetaEnvSeqV1 = null;
|
|
50
66
|
/**
|
|
51
67
|
* Compresses, encrypts, and sends delta data via UDP.
|
|
52
68
|
* Pipeline: Serialize -> Compress -> Encrypt (AES-256-GCM) -> Send
|
|
@@ -131,6 +147,73 @@ function createPipeline(app, state, metricsApi) {
|
|
|
131
147
|
}
|
|
132
148
|
}
|
|
133
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Sends Signal K path metadata to the receiver using the v1 wire format on a
|
|
152
|
+
* separate UDP port.
|
|
153
|
+
*
|
|
154
|
+
* v1 has no packet-type byte so we cannot multiplex meta with deltas on the
|
|
155
|
+
* existing port without breaking every deployed v1 receiver. To keep the
|
|
156
|
+
* change backward-compatible, meta is sent on `udpMetaPort` with a 4-byte
|
|
157
|
+
* `SKM1` magic prefix inside the encrypted plaintext — a v1 receiver that
|
|
158
|
+
* has not been upgraded will fail to JSON-parse the payload and simply drop
|
|
159
|
+
* it without side effects.
|
|
160
|
+
*/
|
|
161
|
+
async function packCryptMeta(entries, kind, secretKey, udpAddress, udpMetaPort) {
|
|
162
|
+
try {
|
|
163
|
+
if (!state.options) {
|
|
164
|
+
app.debug("packCryptMeta called but plugin is stopped, ignoring");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!udpMetaPort || udpMetaPort <= 0) {
|
|
168
|
+
app.debug("packCryptMeta: no udpMetaPort configured, meta disabled on v1");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const usePathDict = !!state.options.usePathDictionary;
|
|
175
|
+
const useMsgpack = !!state.options.useMsgpack;
|
|
176
|
+
const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
|
|
177
|
+
const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
|
|
178
|
+
const envelopeSeq = metaEnvelopeSeqV1++ >>> 0;
|
|
179
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
180
|
+
const chunk = chunks[i];
|
|
181
|
+
const processed = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
|
|
182
|
+
const envelope = (0, metadata_1.buildMetaEnvelope)(processed, kind, envelopeSeq, i, chunks.length);
|
|
183
|
+
const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
|
|
184
|
+
const withMagic = Buffer.concat([V1_META_MAGIC, serialized]);
|
|
185
|
+
const compressed = await (0, pipeline_utils_1.compressPayload)(withMagic, useMsgpack);
|
|
186
|
+
const packet = (0, crypto_1.encryptBinary)(compressed, secretKey, {
|
|
187
|
+
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
188
|
+
});
|
|
189
|
+
if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
|
|
190
|
+
app.debug(`Warning: v1 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD})`);
|
|
191
|
+
}
|
|
192
|
+
await udpSendAsync(packet, udpAddress, udpMetaPort);
|
|
193
|
+
metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
|
|
194
|
+
metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
|
|
195
|
+
metrics.bandwidth.bytesOut += packet.length;
|
|
196
|
+
metrics.bandwidth.packetsOut++;
|
|
197
|
+
}
|
|
198
|
+
if (kind === "snapshot") {
|
|
199
|
+
metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
app.debug(`v1 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
208
|
+
app.error(`packCryptMeta error: ${msg}`);
|
|
209
|
+
recordError("general", `packCryptMeta error: ${msg}`);
|
|
210
|
+
// Re-throw so the caller (sendMetaEntries) can tell the send failed
|
|
211
|
+
// and refrain from committing the MetaCache. Without this, a broken
|
|
212
|
+
// socket/encryption/compression would silently suppress every future
|
|
213
|
+
// diff for the affected paths.
|
|
214
|
+
throw error instanceof Error ? error : new Error(msg);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
134
217
|
/**
|
|
135
218
|
* Decompresses, decrypts, and processes received UDP data.
|
|
136
219
|
* Pipeline: Receive -> Decrypt (AES-256-GCM) -> Decompress -> Parse -> Process
|
|
@@ -206,6 +289,11 @@ function createPipeline(app, state, metricsApi) {
|
|
|
206
289
|
app.debug(`Skipping null delta message after decoding at index ${i}`);
|
|
207
290
|
continue;
|
|
208
291
|
}
|
|
292
|
+
deltaMessage = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(deltaMessage);
|
|
293
|
+
if (deltaMessage === null) {
|
|
294
|
+
app.debug(`Skipping delta with no valid Signal K values at index ${i}`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
209
297
|
// Track path stats for server-side analytics
|
|
210
298
|
trackPathStats(deltaMessage, decompressed.length / deltas.length);
|
|
211
299
|
app.handleMessage("", deltaMessage);
|
|
@@ -267,6 +355,138 @@ function createPipeline(app, state, metricsApi) {
|
|
|
267
355
|
}
|
|
268
356
|
});
|
|
269
357
|
}
|
|
270
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Receive-side counterpart to `packCryptMeta` for v1. Decrypts a packet
|
|
360
|
+
* arrived on `udpMetaPort`, verifies the 4-byte `SKM1` magic inside the
|
|
361
|
+
* plaintext (packets without the magic are dropped — v1 has no packet-type
|
|
362
|
+
* byte, so the magic is the only signal that this is a meta payload and not
|
|
363
|
+
* a corrupted delta), and dispatches each entry as a minimal Signal K delta
|
|
364
|
+
* with `updates[].meta[]` via `app.handleMessage`.
|
|
365
|
+
*/
|
|
366
|
+
async function unpackDecryptMeta(packet, secretKey) {
|
|
367
|
+
try {
|
|
368
|
+
if (!state.options) {
|
|
369
|
+
app.debug("unpackDecryptMeta called but plugin is stopped, ignoring");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Bump bytesIn/packetsIn AND the meta-scoped counters at the same
|
|
373
|
+
// gate — any packet that reached this code is a meta packet (the
|
|
374
|
+
// separate udpMetaPort ensures that), so bytesIn should always equal
|
|
375
|
+
// metaBytesIn for this pipeline path. Keeping them in lockstep lets
|
|
376
|
+
// consumers cross-check: bytesIn === dataBytesIn + metaBytesIn.
|
|
377
|
+
metrics.bandwidth.bytesIn += packet.length;
|
|
378
|
+
metrics.bandwidth.packetsIn++;
|
|
379
|
+
metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + packet.length;
|
|
380
|
+
metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
|
|
381
|
+
const decrypted = (0, crypto_1.decryptBinary)(packet, secretKey, {
|
|
382
|
+
stretchAsciiKey: !!state.options.stretchAsciiKey
|
|
383
|
+
});
|
|
384
|
+
const decompressed = (await (0, pipeline_utils_1.brotliDecompressAsync)(decrypted, {
|
|
385
|
+
maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
|
|
386
|
+
}));
|
|
387
|
+
if (decompressed.length < V1_META_MAGIC.length) {
|
|
388
|
+
app.debug("v1 meta: decompressed payload too short, ignoring");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Reject anything that isn't prefixed with the SKM1 magic so a stray
|
|
392
|
+
// non-meta packet on the meta port (misconfiguration, replay, attacker)
|
|
393
|
+
// cannot be misinterpreted. The magic lives INSIDE the encrypted
|
|
394
|
+
// plaintext, so this check is authenticated.
|
|
395
|
+
if (decompressed.subarray(0, V1_META_MAGIC.length).compare(V1_META_MAGIC) !== 0) {
|
|
396
|
+
app.debug("v1 meta: missing SKM1 magic, dropping");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const body = decompressed.subarray(V1_META_MAGIC.length);
|
|
400
|
+
if (body.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
|
|
401
|
+
app.error(`v1 meta: payload too large to parse: ${body.length} bytes`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let content;
|
|
405
|
+
if (state.options.useMsgpack) {
|
|
406
|
+
try {
|
|
407
|
+
content = msgpack.decode(body);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
content = JSON.parse(body.toString());
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
content = JSON.parse(body.toString());
|
|
415
|
+
}
|
|
416
|
+
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
|
417
|
+
app.debug("v1 meta: envelope was not an object, dropping");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const env = content;
|
|
421
|
+
if (!Array.isArray(env.entries) || env.entries.length === 0) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Drop stale/duplicate envelopes. The inner envelope `seq` is shared
|
|
425
|
+
// across all chunks of the same batch, so equal-seq chunks are still
|
|
426
|
+
// accepted; only earlier batches are rejected. Uint32-wrap aware so a
|
|
427
|
+
// long-running sender's wrap doesn't trigger mass-rejection.
|
|
428
|
+
//
|
|
429
|
+
// Sender-restart detection: the v1 client's meta envelope counter
|
|
430
|
+
// initialises to 0 at process start. Treat envSeq=0 as a peer restart
|
|
431
|
+
// only once lastIngestedMetaEnvSeqV1 has advanced beyond a small
|
|
432
|
+
// reorder window — below the threshold, envSeq=0 is ambiguous with
|
|
433
|
+
// first-packet replay and falls through to normal dedup.
|
|
434
|
+
if (typeof env.seq === "number" && Number.isFinite(env.seq)) {
|
|
435
|
+
const envSeq = env.seq >>> 0;
|
|
436
|
+
if (lastIngestedMetaEnvSeqV1 !== null &&
|
|
437
|
+
envSeq === 0 &&
|
|
438
|
+
lastIngestedMetaEnvSeqV1 >= META_RESTART_THRESHOLD_V1) {
|
|
439
|
+
app.debug(`v1 meta: sender restart detected (last seq was ${lastIngestedMetaEnvSeqV1}); resetting`);
|
|
440
|
+
lastIngestedMetaEnvSeqV1 = null;
|
|
441
|
+
}
|
|
442
|
+
if (lastIngestedMetaEnvSeqV1 !== null) {
|
|
443
|
+
const distance = (envSeq - lastIngestedMetaEnvSeqV1) >>> 0;
|
|
444
|
+
if (distance !== 0 && distance >= 0x80000000) {
|
|
445
|
+
app.debug(`v1 meta: stale envelope seq=${envSeq} (last=${lastIngestedMetaEnvSeqV1}), dropping`);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (distance !== 0) {
|
|
449
|
+
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
lastIngestedMetaEnvSeqV1 = envSeq;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const nowIso = new Date().toISOString();
|
|
457
|
+
const usePathDict = !!state.options.usePathDictionary;
|
|
458
|
+
for (const rawEntry of env.entries) {
|
|
459
|
+
if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const entry = usePathDict
|
|
463
|
+
? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
|
|
464
|
+
: rawEntry;
|
|
465
|
+
const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
|
|
466
|
+
if (!path) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
|
|
470
|
+
const delta = {
|
|
471
|
+
context,
|
|
472
|
+
updates: [
|
|
473
|
+
{
|
|
474
|
+
timestamp: nowIso,
|
|
475
|
+
values: [],
|
|
476
|
+
meta: [{ path, value: entry.meta }]
|
|
477
|
+
}
|
|
478
|
+
]
|
|
479
|
+
};
|
|
480
|
+
app.handleMessage("", delta);
|
|
481
|
+
}
|
|
482
|
+
app.debug(`v1 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}`);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
486
|
+
app.error(`unpackDecryptMeta error: ${msg}`);
|
|
487
|
+
recordError("general", `unpackDecryptMeta error: ${msg}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { packCrypt, packCryptMeta, unpackDecrypt, unpackDecryptMeta };
|
|
271
491
|
}
|
|
272
492
|
module.exports = createPipeline;
|
package/lib/prometheus.js
CHANGED
|
@@ -42,6 +42,10 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
|
|
|
42
42
|
// Delta counters
|
|
43
43
|
counter("deltas_sent_total", "Total deltas sent", metrics.deltasSent);
|
|
44
44
|
counter("deltas_received_total", "Total deltas received", metrics.deltasReceived);
|
|
45
|
+
counter("data_packets_received_total", "Total v2 data packets received", metrics.dataPacketsReceived || 0);
|
|
46
|
+
counter("rate_limited_packets_total", "Total packets dropped by rate limiting", metrics.rateLimitedPackets || 0);
|
|
47
|
+
counter("dropped_delta_batches_total", "Total delta batches dropped before send", metrics.droppedDeltaBatches || 0);
|
|
48
|
+
counter("dropped_deltas_total", "Total deltas dropped before send", metrics.droppedDeltaCount || 0);
|
|
45
49
|
// Error counters
|
|
46
50
|
counter("udp_send_errors_total", "Total UDP send errors", metrics.udpSendErrors);
|
|
47
51
|
counter("udp_retries_total", "Total UDP send retries", metrics.udpRetries);
|
|
@@ -63,6 +67,13 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
|
|
|
63
67
|
counter("bytes_in_raw_total", "Total bytes received (raw/uncompressed)", metrics.bandwidth.bytesInRaw);
|
|
64
68
|
counter("packets_out_total", "Total packets sent", metrics.bandwidth.packetsOut);
|
|
65
69
|
counter("packets_in_total", "Total packets received", metrics.bandwidth.packetsIn);
|
|
70
|
+
counter("metadata_bytes_out_total", "Total bytes sent as metadata packets", metrics.bandwidth.metaBytesOut || 0);
|
|
71
|
+
counter("metadata_bytes_in_total", "Total bytes received as metadata packets", metrics.bandwidth.metaBytesIn || 0);
|
|
72
|
+
counter("metadata_packets_out_total", "Total metadata packets sent", metrics.bandwidth.metaPacketsOut || 0);
|
|
73
|
+
counter("metadata_packets_in_total", "Total metadata packets received", metrics.bandwidth.metaPacketsIn || 0);
|
|
74
|
+
counter("metadata_snapshots_sent_total", "Total metadata snapshot envelopes sent", metrics.bandwidth.metaSnapshotsSent || 0);
|
|
75
|
+
counter("metadata_diffs_sent_total", "Total metadata diff envelopes sent", metrics.bandwidth.metaDiffsSent || 0);
|
|
76
|
+
counter("metadata_rate_limited_packets_total", "Total metadata packets dropped by rate limiting", metrics.bandwidth.metaRateLimitedPackets || 0);
|
|
66
77
|
gauge("bandwidth_rate_out_bytes", "Current outbound bandwidth (bytes/s)", metrics.bandwidth.rateOut);
|
|
67
78
|
gauge("bandwidth_rate_in_bytes", "Current inbound bandwidth (bytes/s)", metrics.bandwidth.rateIn);
|
|
68
79
|
gauge("compression_ratio_percent", "Current compression ratio percentage", metrics.bandwidth.compressionRatio);
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validateRuntimeConfigBody = validateRuntimeConfigBody;
|
|
4
|
+
const metadata_1 = require("../metadata");
|
|
5
|
+
/** Upper bound on meta.includePathsMatching length. Same constant as the
|
|
6
|
+
* metadata runtime — mirrored here so the validator rejects patterns at
|
|
7
|
+
* save time rather than letting the runtime silently fall back to
|
|
8
|
+
* allow-all. */
|
|
9
|
+
const META_FILTER_MAX_LENGTH = 256;
|
|
4
10
|
function validateRuntimeConfigBody(filename, body) {
|
|
5
11
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
6
12
|
return "Request body must be a JSON object";
|
|
@@ -23,6 +29,49 @@ function validateRuntimeConfigBody(filename, body) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
}
|
|
32
|
+
if (body.meta !== undefined) {
|
|
33
|
+
if (body.meta === null || typeof body.meta !== "object" || Array.isArray(body.meta)) {
|
|
34
|
+
return "meta must be an object";
|
|
35
|
+
}
|
|
36
|
+
const m = body.meta;
|
|
37
|
+
if (m.enabled !== undefined && typeof m.enabled !== "boolean") {
|
|
38
|
+
return "meta.enabled must be a boolean";
|
|
39
|
+
}
|
|
40
|
+
if (m.intervalSec !== undefined &&
|
|
41
|
+
(typeof m.intervalSec !== "number" ||
|
|
42
|
+
!Number.isFinite(m.intervalSec) ||
|
|
43
|
+
m.intervalSec < 30 ||
|
|
44
|
+
m.intervalSec > 86400)) {
|
|
45
|
+
return "meta.intervalSec must be a number between 30 and 86400";
|
|
46
|
+
}
|
|
47
|
+
if (m.includePathsMatching !== undefined && m.includePathsMatching !== null) {
|
|
48
|
+
if (typeof m.includePathsMatching !== "string") {
|
|
49
|
+
return "meta.includePathsMatching must be a string or null";
|
|
50
|
+
}
|
|
51
|
+
// Same three checks the runtime applies, hoisted to save-time so
|
|
52
|
+
// persisted subscription.json cannot contain patterns the runtime
|
|
53
|
+
// would silently ignore. Keeps the API and the runtime in agreement.
|
|
54
|
+
if (m.includePathsMatching.length > META_FILTER_MAX_LENGTH) {
|
|
55
|
+
return `meta.includePathsMatching must be at most ${META_FILTER_MAX_LENGTH} characters`;
|
|
56
|
+
}
|
|
57
|
+
if ((0, metadata_1.isLikelyUnsafePathFilter)(m.includePathsMatching)) {
|
|
58
|
+
return "meta.includePathsMatching contains a nested unbounded quantifier (ReDoS shape); refused";
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
new RegExp(m.includePathsMatching);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return `meta.includePathsMatching failed to compile: ${err instanceof Error ? err.message : String(err)}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (m.maxPathsPerPacket !== undefined &&
|
|
68
|
+
(typeof m.maxPathsPerPacket !== "number" ||
|
|
69
|
+
!Number.isFinite(m.maxPathsPerPacket) ||
|
|
70
|
+
m.maxPathsPerPacket < 10 ||
|
|
71
|
+
m.maxPathsPerPacket > 5000)) {
|
|
72
|
+
return "meta.maxPathsPerPacket must be a number between 10 and 5000";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
26
75
|
}
|
|
27
76
|
else if (filename === "sentence_filter.json") {
|
|
28
77
|
if (body.excludedSentences !== undefined && !Array.isArray(body.excludedSentences)) {
|
package/lib/routes/metrics.js
CHANGED
|
@@ -37,6 +37,7 @@ function register(router, ctx) {
|
|
|
37
37
|
packetLoss: effectiveNetwork.packetLoss,
|
|
38
38
|
retransmissions: effectiveNetwork.retransmissions,
|
|
39
39
|
queueDepth: effectiveNetwork.queueDepth,
|
|
40
|
+
retransmitRate: effectiveNetwork.retransmitRate,
|
|
40
41
|
acksSent: metrics.acksSent || 0,
|
|
41
42
|
naksSent: metrics.naksSent || 0,
|
|
42
43
|
activeLink: effectiveNetwork.activeLink,
|
package/lib/routes.js
CHANGED
|
@@ -289,6 +289,12 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
289
289
|
}
|
|
290
290
|
return localVal ?? 0;
|
|
291
291
|
}
|
|
292
|
+
const bondingManager = state.pipeline && state.pipeline.getBondingManager
|
|
293
|
+
? state.pipeline.getBondingManager()
|
|
294
|
+
: null;
|
|
295
|
+
const localActiveLink = bondingManager
|
|
296
|
+
? bondingManager.getActiveLinkName() || "primary"
|
|
297
|
+
: "primary";
|
|
292
298
|
return {
|
|
293
299
|
rtt: selectMetric(remote.rtt, metrics.rtt),
|
|
294
300
|
jitter: selectMetric(remote.jitter, metrics.jitter),
|
|
@@ -296,7 +302,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
296
302
|
retransmissions: selectMetric(remote.retransmissions, metrics.retransmissions),
|
|
297
303
|
queueDepth: selectMetric(remote.queueDepth, metrics.queueDepth),
|
|
298
304
|
retransmitRate: selectMetric(remote.retransmitRate, clientRetransmitRate),
|
|
299
|
-
activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") :
|
|
305
|
+
activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") : localActiveLink,
|
|
300
306
|
dataSource: hasFreshRemote ? "remote-client" : "local",
|
|
301
307
|
lastUpdate: hasFreshRemote ? remote.lastUpdate : 0
|
|
302
308
|
};
|
|
@@ -336,6 +342,10 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
336
342
|
subscriptionErrors: metrics.subscriptionErrors,
|
|
337
343
|
duplicatePackets: metrics.duplicatePackets || 0,
|
|
338
344
|
malformedPackets: metrics.malformedPackets || 0,
|
|
345
|
+
dataPacketsReceived: metrics.dataPacketsReceived || 0,
|
|
346
|
+
rateLimitedPackets: metrics.rateLimitedPackets || 0,
|
|
347
|
+
droppedDeltaBatches: metrics.droppedDeltaBatches || 0,
|
|
348
|
+
droppedDeltaCount: metrics.droppedDeltaCount || 0,
|
|
339
349
|
errorCounts: { ...(metrics.errorCounts || {}) }
|
|
340
350
|
},
|
|
341
351
|
status: {
|
|
@@ -348,6 +358,8 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
348
358
|
: metrics.bandwidth.packetsOut;
|
|
349
359
|
const bytes = state.isServerMode ? metrics.bandwidth.bytesIn : metrics.bandwidth.bytesOut;
|
|
350
360
|
const avgPacketSize = packets > 0 ? Math.round(bytes / packets) : 0;
|
|
361
|
+
const metaBytesOut = metrics.bandwidth.metaBytesOut || 0;
|
|
362
|
+
const metaBytesIn = metrics.bandwidth.metaBytesIn || 0;
|
|
351
363
|
return {
|
|
352
364
|
bytesOut: metrics.bandwidth.bytesOut,
|
|
353
365
|
bytesIn: metrics.bandwidth.bytesIn,
|
|
@@ -356,6 +368,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
356
368
|
bytesOutFormatted: formatBytes(metrics.bandwidth.bytesOut),
|
|
357
369
|
bytesInFormatted: formatBytes(metrics.bandwidth.bytesIn),
|
|
358
370
|
bytesOutRawFormatted: formatBytes(metrics.bandwidth.bytesOutRaw),
|
|
371
|
+
bytesInRawFormatted: formatBytes(metrics.bandwidth.bytesInRaw),
|
|
359
372
|
packetsOut: metrics.bandwidth.packetsOut,
|
|
360
373
|
packetsIn: metrics.bandwidth.packetsIn,
|
|
361
374
|
rateOut: metrics.bandwidth.rateOut,
|
|
@@ -365,6 +378,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
365
378
|
compressionRatio: metrics.bandwidth.compressionRatio,
|
|
366
379
|
avgPacketSize,
|
|
367
380
|
avgPacketSizeFormatted: avgPacketSize > 0 ? formatBytes(avgPacketSize) : "0 B",
|
|
381
|
+
metaBytesOut,
|
|
382
|
+
metaBytesIn,
|
|
383
|
+
metaBytesOutFormatted: formatBytes(metaBytesOut),
|
|
384
|
+
metaBytesInFormatted: formatBytes(metaBytesIn),
|
|
385
|
+
metaPacketsOut: metrics.bandwidth.metaPacketsOut || 0,
|
|
386
|
+
metaPacketsIn: metrics.bandwidth.metaPacketsIn || 0,
|
|
387
|
+
metaSnapshotsSent: metrics.bandwidth.metaSnapshotsSent || 0,
|
|
388
|
+
metaDiffsSent: metrics.bandwidth.metaDiffsSent || 0,
|
|
389
|
+
metaRateLimitedPackets: metrics.bandwidth.metaRateLimitedPackets || 0,
|
|
368
390
|
history: metrics.bandwidth.history.toArray().slice(-30)
|
|
369
391
|
};
|
|
370
392
|
})(),
|
|
@@ -387,16 +409,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
|
|
|
387
409
|
packetLoss: effectiveNetwork.packetLoss,
|
|
388
410
|
retransmissions: effectiveNetwork.retransmissions,
|
|
389
411
|
queueDepth: effectiveNetwork.queueDepth,
|
|
412
|
+
retransmitRate: effectiveNetwork.retransmitRate,
|
|
390
413
|
acksSent: metrics.acksSent || 0,
|
|
391
414
|
naksSent: metrics.naksSent || 0,
|
|
415
|
+
activeLink: effectiveNetwork.activeLink,
|
|
392
416
|
dataSource: effectiveNetwork.dataSource
|
|
393
417
|
};
|
|
394
418
|
if (state.isServerMode && effectiveNetwork.lastUpdate > 0) {
|
|
395
419
|
networkData.lastRemoteUpdate = effectiveNetwork.lastUpdate;
|
|
396
420
|
}
|
|
397
|
-
if (state.isServerMode) {
|
|
398
|
-
networkData.activeLink = effectiveNetwork.activeLink;
|
|
399
|
-
}
|
|
400
421
|
const publisher = getActiveMetricsPublisher(state);
|
|
401
422
|
if (publisher) {
|
|
402
423
|
networkData.linkQuality = publisher.calculateLinkQuality({
|
package/package.json
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";(self.webpackChunksignalk_edge_link=self.webpackChunksignalk_edge_link||[]).push([[982],{4353(e,t,n){n.r(t),n.d(t,{default:()=>K});var r=n(4147),i=n(6718),o=n(4810),a=n(7936);const s="Management token required/invalid.",l={token:null,localStorageKey:"signalkEdgeLinkManagementToken",queryParam:"edgeLinkToken",includeTokenInQuery:!1,headerMode:"both"};function c(e,t={}){const n=function(){if("undefined"==typeof window)return l;const e=window.__EDGE_LINK_AUTH__;return e&&"object"==typeof e?{...l,...e}:l}(),r=function(e){if(e.token)return String(e.token).trim();if("undefined"==typeof window)return"";if(e.includeTokenInQuery&&e.queryParam){const t=new URLSearchParams(window.location.search).get(e.queryParam);if(t)return t.trim()}if(e.localStorageKey&&window.localStorage){const t=window.localStorage.getItem(e.localStorageKey);if(t)return t.trim()}return""}(n),i=new Headers(t.headers||{});return function(e,t,n){if(!t)return e;const r=(n||"both").toLowerCase();"x-edge-link-token"!==r&&"token"!==r&&"both"!==r||e.set("X-Edge-Link-Token",t),"authorization"!==r&&"bearer"!==r&&"both"!==r||e.set("Authorization",`Bearer ${t}`)}(i,r,n.headerMode),fetch(e,{...t,headers:i})}const d={name:{type:"string",title:"Connection Name",description:"Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.",default:"connection",maxLength:40},serverType:{type:"string",title:"Operation Mode",description:"Select Server to receive data, or Client to send data.",default:"client",oneOf:[{const:"server",title:"Server Mode – Receive Data"},{const:"client",title:"Client Mode – Send Data"}]},udpPort:{type:"number",title:"UDP Port",description:"UDP port for data transmission (must match on both ends).",default:4446,minimum:1024,maximum:65535},secretKey:{type:"string",title:"Encryption Key",description:"32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64.",minLength:32,maxLength:64,pattern:"^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$"},stretchAsciiKey:{type:"boolean",title:"Stretch 32-char ASCII Key (PBKDF2)",description:`When the secretKey is 32-character ASCII, route it through PBKDF2-SHA256 (${6e5.toLocaleString("en-US")} iterations) to raise it to full 256-bit AES strength. Hex and base64 keys are unaffected. BOTH ENDS OF THE CONNECTION MUST USE THE SAME SETTING — otherwise authentication will fail and every packet will be dropped.`,default:!1},useMsgpack:{type:"boolean",title:"Use MessagePack",description:"Binary serialization for smaller payloads (must match on both ends).",default:!1},usePathDictionary:{type:"boolean",title:"Use Path Dictionary",description:"Encode paths as numeric IDs for bandwidth savings (must match on both ends).",default:!1},protocolVersion:{type:"number",title:"Protocol Version",description:"v1: encrypted UDP. v2 adds reliable delivery and metrics. v3 keeps the v2 data path and authenticates control packets (ACK/NAK/HEARTBEAT/HELLO). Must match on both ends.",default:1,oneOf:[{const:1,title:"v1 – Standard encrypted UDP"},{const:2,title:"v2 – Reliability, congestion control, bonding, metrics"},{const:3,title:"v3 - v2 features with authenticated control packets"}]}},m={udpAddress:{type:"string",title:"Server Address",description:"IP address or hostname of the remote Signal K endpoint.",default:"127.0.0.1"},helloMessageSender:{type:"integer",title:"Heartbeat Interval (seconds)",description:"Send periodic heartbeat messages to keep NAT/firewall mappings alive.",default:60,minimum:10,maximum:3600},testAddress:{type:"string",title:"Connectivity Test Address",description:"Host used for reachability checks (e.g. 8.8.8.8).",default:"127.0.0.1"},testPort:{type:"number",title:"Connectivity Test Port",description:"Port used for reachability checks (e.g. 53, 80, or 443).",default:80,minimum:1,maximum:65535},pingIntervalTime:{type:"number",title:"Check Interval (minutes)",description:"Frequency of network reachability checks.",default:1,minimum:.1,maximum:60},heartbeatInterval:{type:"number",title:"NAT Keepalive Heartbeat Interval (ms)",description:"v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.",default:25e3,minimum:5e3,maximum:12e4}},u={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits.",properties:{retransmitQueueSize:{type:"number",title:"Retransmit Queue Size",description:"Maximum number of sent packets stored for potential retransmission.",default:5e3,minimum:100,maximum:5e4},maxRetransmits:{type:"number",title:"Max Retransmit Attempts",description:"Maximum resend attempts before a packet is dropped from the retransmit queue.",default:3,minimum:1,maximum:20},retransmitMaxAge:{type:"number",title:"Retransmit Max Age (ms)",description:"Expire stale unacknowledged packets older than this age.",default:12e4,minimum:1e3,maximum:3e5},retransmitMinAge:{type:"number",title:"Retransmit Min Age (ms)",description:"Minimum packet age before expiration is allowed.",default:1e4,minimum:200,maximum:3e4},retransmitRttMultiplier:{type:"number",title:"RTT Expiry Multiplier",description:"Dynamic expiry age = RTT × this multiplier.",default:12,minimum:2,maximum:20},ackIdleDrainAge:{type:"number",title:"ACK Idle Drain Age (ms)",description:"If ACKs are idle longer than this, expiry becomes more aggressive.",default:2e4,minimum:500,maximum:3e4},forceDrainAfterAckIdle:{type:"boolean",title:"Force Drain After ACK Idle",description:"When enabled, clear retransmit queue if no ACKs arrive for too long.",default:!1},forceDrainAfterMs:{type:"number",title:"Force Drain Timeout (ms)",description:"ACK idle duration before force-draining retransmit queue to zero.",default:45e3,minimum:2e3,maximum:12e4},recoveryBurstEnabled:{type:"boolean",title:"Recovery Burst Enabled",description:"When ACKs return after outage, rapidly retransmit queued packets to catch up.",default:!0},recoveryBurstSize:{type:"number",title:"Recovery Burst Size",description:"Max queued packets to retransmit per recovery burst cycle.",default:100,minimum:10,maximum:1e3},recoveryBurstIntervalMs:{type:"number",title:"Recovery Burst Interval (ms)",description:"Interval between recovery burst cycles while backlog exists.",default:200,minimum:50,maximum:5e3},recoveryAckGapMs:{type:"number",title:"Recovery ACK Gap (ms)",description:"Minimum ACK silence before triggering fast recovery bursts.",default:4e3,minimum:500,maximum:12e4}}},p={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery.",properties:{ackInterval:{type:"number",title:"ACK Interval (ms)",description:"How often server sends cumulative ACK updates.",default:100,minimum:20,maximum:5e3},ackResendInterval:{type:"number",title:"ACK Resend Interval (ms)",description:"Re-send duplicate ACK periodically to recover from lost ACK packets.",default:1e3,minimum:100,maximum:1e4},nakTimeout:{type:"number",title:"NAK Timeout (ms)",description:"Delay before requesting retransmission for missing sequence numbers.",default:100,minimum:20,maximum:5e3}}},g={type:"object",title:"Dynamic Congestion Control (v2/v3 only)",description:"Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions.",properties:{enabled:{type:"boolean",title:"Enable Congestion Control",description:"Automatically adjust delta timer based on RTT and packet loss.",default:!1},targetRTT:{type:"number",title:"Target RTT (ms)",description:"RTT threshold above which send rate is reduced.",default:200,minimum:50,maximum:2e3},nominalDeltaTimer:{type:"number",title:"Nominal Delta Timer (ms)",description:"Preferred steady-state send interval.",default:1e3,minimum:100,maximum:1e4},minDeltaTimer:{type:"number",title:"Minimum Delta Timer (ms)",description:"Fastest allowed send interval.",default:100,minimum:50,maximum:1e3},maxDeltaTimer:{type:"number",title:"Maximum Delta Timer (ms)",description:"Slowest allowed send interval.",default:5e3,minimum:1e3,maximum:3e4}}},f={type:"object",title:"Connection Bonding (v2/v3 only)",description:"Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections.",properties:{enabled:{type:"boolean",title:"Enable Connection Bonding",description:"Enable dual-link bonding with automatic failover.",default:!1},mode:{type:"string",title:"Bonding Mode",description:"Bonding operating mode.",default:"main-backup",oneOf:[{const:"main-backup",title:"Main/Backup – Failover to backup when primary degrades"}]},primary:{type:"object",title:"Primary Link",description:"Primary connection (e.g. LTE modem).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4446,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},backup:{type:"object",title:"Backup Link",description:"Backup connection (e.g. Starlink, satellite).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4447,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},failover:{type:"object",title:"Failover Thresholds",description:"Configure when failover is triggered.",properties:{rttThreshold:{type:"number",title:"RTT Threshold (ms)",default:500,minimum:100,maximum:5e3},lossThreshold:{type:"number",title:"Packet Loss Threshold (0-1)",default:.1,minimum:.01,maximum:.5},healthCheckInterval:{type:"number",title:"Health Check Interval (ms)",default:1e3,minimum:500,maximum:1e4},failbackDelay:{type:"number",title:"Failback Delay (ms)",default:3e4,minimum:5e3,maximum:3e5},heartbeatTimeout:{type:"number",title:"Heartbeat Timeout (ms)",default:5e3,minimum:1e3,maximum:3e4}}}}},y={type:"boolean",title:"Enable Signal K Notifications",description:"Emit Signal K notifications for alerts and failover events.",default:!1},b={type:"object",title:"Monitoring Alert Thresholds (v2/v3 only)",description:"Customize warning/critical thresholds for network monitoring alerts.",properties:{rtt:{type:"object",title:"RTT Thresholds",properties:{warning:{type:"number",title:"Warning RTT (ms)",default:300},critical:{type:"number",title:"Critical RTT (ms)",default:800}}},packetLoss:{type:"object",title:"Packet Loss Thresholds",properties:{warning:{type:"number",title:"Warning Loss Ratio",default:.03},critical:{type:"number",title:"Critical Loss Ratio",default:.1}}},retransmitRate:{type:"object",title:"Retransmit Rate Thresholds",properties:{warning:{type:"number",title:"Warning Retransmit Ratio",default:.05},critical:{type:"number",title:"Critical Retransmit Ratio",default:.15}}},jitter:{type:"object",title:"Jitter Thresholds",properties:{warning:{type:"number",title:"Warning Jitter (ms)",default:100},critical:{type:"number",title:"Critical Jitter (ms)",default:300}}},queueDepth:{type:"object",title:"Queue Depth Thresholds",properties:{warning:{type:"number",title:"Warning Queue Depth",default:100},critical:{type:"number",title:"Critical Queue Depth",default:500}}}}};function h(e,t){const n=Number(t)>=2,r={...d},i=["serverType","udpPort","secretKey"];return e?(Object.assign(r,m),r.enableNotifications=y,i.push("udpAddress","testAddress","testPort"),n&&(r.reliability=u,r.congestionControl=g,r.bonding=f,r.alertThresholds=b)):n&&(r.reliability=p),{type:"object",required:i,properties:r}}const k="/plugins/signalk-edge-link";let v=0;function x(){return`skel-${Date.now()}-${++v}`}function T(e){return{_id:x(),name:e||"client",serverType:"client",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,enableNotifications:!1,protocolVersion:1,udpAddress:"127.0.0.1",helloMessageSender:60,testAddress:"127.0.0.1",testPort:80,pingIntervalTime:1}}function A(e){return{_id:x(),name:e||"server",serverType:"server",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,protocolVersion:1}}function w(e){return e._id?e:{...e,_id:x()}}function E(e){const t=h("server"!==e.serverType,e.protocolVersion),{_id:n,...r}=e;return{...(0,a.NV)(o.Ay,t,r),_id:n}}function S(e){if(null===e||"object"!=typeof e)return JSON.stringify(e);if(Array.isArray(e))return"["+e.map(S).join(",")+"]";const t=e;return"{"+Object.keys(t).sort().map(e=>JSON.stringify(e)+":"+S(t[e])).join(",")+"}"}const C={"ui:order":["name","serverType","udpAddress","udpPort","secretKey","stretchAsciiKey","protocolVersion","useMsgpack","usePathDictionary","testAddress","testPort","pingIntervalTime","helloMessageSender","reliability","congestionControl","bonding","enableNotifications","alertThresholds"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"},reliability:{"ui:classNames":"skel-optional-group"},congestionControl:{"ui:classNames":"skel-optional-group"},bonding:{"ui:classNames":"skel-optional-group"},alertThresholds:{"ui:classNames":"skel-optional-group"}},P={"ui:order":["name","serverType","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion","reliability"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"}},N=["name","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion"];function I({conn:e,index:t,totalCount:n,expanded:a,onToggle:s,onChange:l,onRemove:c}){const d="server"!==e.serverType,m=h(d,e.protocolVersion),u=d?C:P,p=d?"Client":"Server",g=(e.name||`Connection ${t+1}`).trim(),{_id:f,...y}=e;return r.createElement("div",{className:"skel-card"},r.createElement("div",{className:"skel-card-header",onClick:s,role:"button","aria-expanded":a},r.createElement("span",{className:"skel-badge "+(d?"skel-badge-client":"skel-badge-server")},p),r.createElement("span",{className:"skel-card-title"},g),r.createElement("span",{className:"skel-expand-icon"},a?"▲":"▼"),r.createElement("button",{className:"skel-btn-remove",disabled:n<=1,onClick:e=>{e.stopPropagation(),c()},title:n<=1?"Cannot remove the only connection":"Remove this connection"},"Remove")),a&&r.createElement("div",{className:"skel-card-body"},r.createElement(i.Ay,{schema:m,uiSchema:u,formData:y,validator:o.Ay,onChange:function(t){const n=t.formData;if(n.serverType!==e.serverType){const t={..."server"===n.serverType?A(n.name):T(n.name),_id:e._id};for(const e of N)void 0!==n[e]&&(t[e]=n[e]);return t.serverType=n.serverType,void l(t)}const r={...n,_id:e._id},{_id:i,...o}=r,{_id:a,...s}=e;(function(e,t){const n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(const r of n){if(!Object.prototype.hasOwnProperty.call(t,r))return!1;const n=e[r],i=t[r];if(n!==i){if(null===n||null===i||"object"!=typeof n||"object"!=typeof i)return!1;if(S(n)!==S(i))return!1}}return!0})(o,s)||l(r)},onSubmit:()=>{},liveValidate:!1},r.createElement("div",null))))}const K=function(e){const[t,n]=(0,r.useState)([]),[i,o]=(0,r.useState)(""),[a,l]=(0,r.useState)(!1),[d,m]=(0,r.useState)(!0),[u,p]=(0,r.useState)(null),[g,f]=(0,r.useState)(null),[y,b]=(0,r.useState)(null),[h,v]=(0,r.useState)(0),[x,S]=(0,r.useState)(!1),C=(0,r.useRef)(!1);(0,r.useEffect)(()=>{!async function(){try{const e=await c(`${k}/plugin-config`);if(401===e.status)throw new Error(s);if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const t=await e.json();if(!t.success)throw new Error(t.error||"Failed to load configuration");const r=t.configuration||{};let i;i=Array.isArray(r.connections)&&r.connections.length>0?r.connections.map(e=>E(w(e))):r.serverType?[E(w(r))]:[T()],n(i),o("string"==typeof r.managementApiToken?r.managementApiToken:""),l(!0===r.requireManagementApiToken),v(0),S(!1)}catch(e){p(e instanceof Error?e.message:String(e))}finally{m(!1)}}()},[]);const P=t.filter(e=>"server"===e.serverType).map(e=>e.udpPort),N=new Set(P.filter((e,t)=>P.indexOf(e)!==t));function K(){S(!0),f(null),b(null)}const D=(0,r.useCallback)(async()=>{if(!C.current){if(0===t.length)return b("At least one connection is required before saving."),void f({type:"error",message:"Cannot save an empty configuration. Add at least one connection."});if(b(null),N.size>0)f({type:"error",message:`Duplicate server ports detected: ${[...N].join(", ")}. Each server must use a unique UDP port.`});else{C.current=!0,f({type:"saving",message:"Saving configuration..."});try{const e=t.map(({_id:e,...t})=>t),n=await c(`${k}/plugin-config`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({connections:e,managementApiToken:i,requireManagementApiToken:a})});if(401===n.status)throw new Error(s);const r=await n.json();if(!n.ok||!r.success)throw new Error(r.error||"Failed to save");f({type:"success",message:r.message||"Configuration saved. Plugin restarting..."}),S(!1)}catch(e){f({type:"error",message:e instanceof Error?e.message:String(e)})}finally{C.current=!1}}}},[t,N,i,a]);return d?r.createElement("div",{style:{padding:"20px",textAlign:"center"}},"Loading configuration..."):u?r.createElement("div",{style:{padding:"20px"}},r.createElement("div",{className:"skel-alert skel-alert-error"},r.createElement("strong",null,"Error loading configuration:")," ",u)):r.createElement("div",{className:"skel-config"},r.createElement("style",null,'\n.skel-config { font-family: inherit; }\n.skel-dirty-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: #fff3cd;\n color: #664d03;\n border: 1px solid #ffe69c;\n border-radius: 4px;\n margin-bottom: 12px;\n font-size: 0.88rem;\n}\n.skel-card {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 12px;\n overflow: hidden;\n}\n.skel-card-header {\n display: flex;\n align-items: center;\n padding: 10px 14px;\n background: #f8f9fa;\n cursor: pointer;\n user-select: none;\n gap: 10px;\n}\n.skel-card-header:hover { background: #e9ecef; }\n.skel-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 12px;\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n}\n.skel-badge-server { background: #cfe2ff; color: #084298; }\n.skel-badge-client { background: #d1e7dd; color: #0a3622; }\n.skel-card-title { font-weight: 600; flex: 1; }\n.skel-expand-icon { font-size: 0.8rem; color: #6c757d; }\n.skel-btn-remove {\n background: none;\n border: 1px solid #dc3545;\n color: #dc3545;\n border-radius: 4px;\n padding: 2px 8px;\n font-size: 0.8rem;\n cursor: pointer;\n}\n.skel-btn-remove:hover { background: #dc3545; color: white; }\n.skel-btn-remove:disabled { opacity: 0.4; cursor: default; border-color: #aaa; color: #aaa; }\n.skel-btn-remove:disabled:hover { background: none; }\n.skel-card-body { padding: 16px; border-top: 1px solid #dee2e6; }\n.skel-toolbar {\n display: flex;\n gap: 10px;\n align-items: center;\n margin-top: 16px;\n padding-top: 16px;\n border-top: 1px solid #dee2e6;\n flex-wrap: wrap;\n}\n.skel-btn {\n padding: 7px 16px;\n border-radius: 4px;\n font-size: 0.95rem;\n cursor: pointer;\n border: none;\n}\n.skel-btn-primary { background: #0d6efd; color: white; }\n.skel-btn-primary:hover { background: #0b5ed7; }\n.skel-btn-primary:disabled { background: #6c757d; cursor: default; }\n.skel-btn-secondary { background: white; color: #0d6efd; border: 1px solid #0d6efd; }\n.skel-btn-secondary:hover { background: #e7f0ff; }\n.skel-alert {\n padding: 10px 14px;\n border-radius: 4px;\n margin-bottom: 14px;\n font-size: 0.9rem;\n}\n.skel-alert-success { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }\n.skel-alert-error { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }\n.skel-alert-saving { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }\n.skel-dup-warn { font-size: 0.8rem; color: #dc3545; margin-top: 4px; }\n.skel-plugin-settings {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 20px;\n padding: 16px;\n background: #f8f9fa;\n}\n.skel-plugin-settings h3 {\n margin: 0 0 12px;\n font-size: 1rem;\n font-weight: 600;\n}\n.skel-field-group {\n margin-bottom: 14px;\n}\n.skel-field-group label {\n display: block;\n font-weight: 500;\n margin-bottom: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="text"],\n.skel-field-group input[type="password"] {\n width: 100%;\n max-width: 420px;\n padding: 6px 10px;\n border: 1px solid #ced4da;\n border-radius: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="checkbox"] {\n margin-right: 6px;\n}\n.skel-field-desc {\n font-size: 0.8rem;\n color: #5c6773;\n margin-top: 3px;\n}\n.skel-config .field-description {\n color: #5c6773;\n font-size: 0.83rem;\n line-height: 1.35;\n}\n.skel-config legend,\n.skel-config label {\n line-height: 1.2;\n overflow-wrap: anywhere;\n}\n.skel-optional-group {\n margin-top: 12px;\n border: 1px dashed #ccd5df;\n border-radius: 6px;\n padding: 10px 12px 4px;\n background: #fbfcfe;\n}\n.skel-optional-group legend {\n font-size: 0.92rem;\n margin-bottom: 6px;\n}\n.skel-optional-group .form-group {\n margin-bottom: 10px;\n}\n.skel-optional-group .form-control {\n max-width: 340px;\n}\n'),x&&"saving"!==g?.type&&r.createElement("div",{className:"skel-dirty-banner"},r.createElement("span",null,"⚠"),r.createElement("span",null,"You have unsaved changes.")),g&&r.createElement("div",{className:"skel-alert skel-alert-"+("saving"===g.type?"saving":"success"===g.type?"success":"error")},g.message),r.createElement("div",{className:"skel-plugin-settings"},r.createElement("h3",null,"Plugin Security Settings"),r.createElement("div",{className:"skel-field-group"},r.createElement("label",{htmlFor:"skel-mgmt-token"},"Management API Token"),r.createElement("input",{id:"skel-mgmt-token",type:"password",value:i,placeholder:"Leave empty for open access",onChange:e=>{o(e.target.value),K()},autoComplete:"new-password"}),r.createElement("div",{className:"skel-field-desc"},"Shared secret to protect the management API endpoints. Strongly recommended for production. Can also be set via the"," ",r.createElement("code",null,"SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN")," environment variable (env var takes priority). Leave empty to allow open access.")),r.createElement("div",{className:"skel-field-group"},r.createElement("label",null,r.createElement("input",{type:"checkbox",checked:a,onChange:e=>{l(e.target.checked),K()}}),"Require Management API Token"),r.createElement("div",{className:"skel-field-desc"},"When enabled, all management API requests are rejected if no token is configured (fail-closed). When disabled, requests are allowed if no token is set (open access)."))),t.map((e,i)=>r.createElement("div",{key:e._id},r.createElement(I,{conn:e,index:i,totalCount:t.length,expanded:h===i,onToggle:()=>function(e){v(t=>t===e?null:e)}(i),onChange:e=>function(e,t){n(n=>n.map((n,r)=>r===e?t:n)),K()}(i,e),onRemove:()=>function(e){n(t=>{if(t.length<=1)return t;const n=t.filter((t,n)=>n!==e);return v(t=>null!==t&&t>=e&&t>0?t-1:t),n}),K()}(i)}),"server"===e.serverType&&N.has(e.udpPort)&&r.createElement("div",{className:"skel-dup-warn"},"Port ",e.udpPort," is used by multiple server connections. Each server requires a unique port."))),r.createElement("div",{className:"skel-toolbar"},r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,A(`server-${e.length+1}`)];return v(t.length-1),t}),K()}},"+ Add Server"),r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,T(`client-${e.length+1}`)];return v(t.length-1),t}),K()}},"+ Add Client"),r.createElement("button",{className:"skel-btn skel-btn-primary",onClick:D,disabled:g&&"saving"===g.type||0===t.length},x?"Save Changes":"Save Configuration"),y&&r.createElement("span",{style:{color:"#dc3545",fontSize:"0.85rem",fontWeight:500}},y),r.createElement("span",{style:{fontSize:"0.85rem",color:"#6c757d"}},t.length," connection",1!==t.length?"s":""," · ",t.filter(e=>"server"===e.serverType).length," server",1!==t.filter(e=>"server"===e.serverType).length?"s":"",", ",t.filter(e=>"server"!==e.serverType).length," client",1!==t.filter(e=>"server"!==e.serverType).length?"s":"")))}}}]);
|
|
2
|
-
//# sourceMappingURL=982.
|
|
1
|
+
"use strict";(self.webpackChunksignalk_edge_link=self.webpackChunksignalk_edge_link||[]).push([[982],{4353(e,t,n){n.r(t),n.d(t,{default:()=>K});var r=n(4147),i=n(6718),a=n(4810),o=n(7936);const s="Management token required/invalid.",l={token:null,localStorageKey:"signalkEdgeLinkManagementToken",queryParam:"edgeLinkToken",includeTokenInQuery:!1,headerMode:"both"};function c(e,t={}){const n=function(){if("undefined"==typeof window)return l;const e=window.__EDGE_LINK_AUTH__;return e&&"object"==typeof e?{...l,...e}:l}(),r=function(e){if(e.token)return String(e.token).trim();if("undefined"==typeof window)return"";if(e.includeTokenInQuery&&e.queryParam){const t=new URLSearchParams(window.location.search).get(e.queryParam);if(t)return t.trim()}if(e.localStorageKey&&window.localStorage){const t=window.localStorage.getItem(e.localStorageKey);if(t)return t.trim()}return""}(n),i=new Headers(t.headers||{});return function(e,t,n){if(!t)return e;const r=(n||"both").toLowerCase();"x-edge-link-token"!==r&&"token"!==r&&"both"!==r||e.set("X-Edge-Link-Token",t),"authorization"!==r&&"bearer"!==r&&"both"!==r||e.set("Authorization",`Bearer ${t}`)}(i,r,n.headerMode),fetch(e,{...t,headers:i})}const d={name:{type:"string",title:"Connection Name",description:"Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.",default:"connection",maxLength:40},serverType:{type:"string",title:"Operation Mode",description:"Select Server to receive data, or Client to send data.",default:"client",oneOf:[{const:"server",title:"Server Mode – Receive Data"},{const:"client",title:"Client Mode – Send Data"}]},udpPort:{type:"number",title:"UDP Port",description:"UDP port for data transmission (must match on both ends).",default:4446,minimum:1024,maximum:65535},secretKey:{type:"string",title:"Encryption Key",description:"32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64.",minLength:32,maxLength:64,pattern:"^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$"},stretchAsciiKey:{type:"boolean",title:"Stretch 32-char ASCII Key (PBKDF2)",description:`When the secretKey is 32-character ASCII, route it through PBKDF2-SHA256 (${6e5.toLocaleString("en-US")} iterations) to raise it to full 256-bit AES strength. Hex and base64 keys are unaffected. BOTH ENDS OF THE CONNECTION MUST USE THE SAME SETTING — otherwise authentication will fail and every packet will be dropped.`,default:!1},useMsgpack:{type:"boolean",title:"Use MessagePack",description:"Binary serialization for smaller payloads (must match on both ends).",default:!1},usePathDictionary:{type:"boolean",title:"Use Path Dictionary",description:"Encode paths as numeric IDs for bandwidth savings (must match on both ends).",default:!1},protocolVersion:{type:"number",title:"Protocol Version",description:"v1: encrypted UDP. v2 adds reliable delivery and metrics. v3 keeps the v2 data path and authenticates control packets (ACK/NAK/HEARTBEAT/HELLO). Must match on both ends.",default:1,oneOf:[{const:1,title:"v1 – Standard encrypted UDP"},{const:2,title:"v2 – Reliability, congestion control, bonding, metrics"},{const:3,title:"v3 - v2 features with authenticated control packets"}]}},m={udpAddress:{type:"string",title:"Server Address",description:"IP address or hostname of the remote Signal K endpoint.",default:"127.0.0.1"},helloMessageSender:{type:"integer",title:"Heartbeat Interval (seconds)",description:"Send periodic heartbeat messages to keep NAT/firewall mappings alive.",default:60,minimum:10,maximum:3600},testAddress:{type:"string",title:"Connectivity Test Address",description:"Host used for reachability checks (e.g. 8.8.8.8).",default:"127.0.0.1"},testPort:{type:"number",title:"Connectivity Test Port",description:"Port used for reachability checks (e.g. 53, 80, or 443).",default:80,minimum:1,maximum:65535},pingIntervalTime:{type:"number",title:"Check Interval (minutes)",description:"Frequency of network reachability checks.",default:1,minimum:.1,maximum:60},heartbeatInterval:{type:"number",title:"NAT Keepalive Heartbeat Interval (ms)",description:"v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.",default:25e3,minimum:5e3,maximum:12e4}},u={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits.",properties:{retransmitQueueSize:{type:"number",title:"Retransmit Queue Size",description:"Maximum number of sent packets stored for potential retransmission.",default:5e3,minimum:100,maximum:5e4},maxRetransmits:{type:"number",title:"Max Retransmit Attempts",description:"Maximum resend attempts before a packet is dropped from the retransmit queue.",default:3,minimum:1,maximum:20},retransmitMaxAge:{type:"number",title:"Retransmit Max Age (ms)",description:"Expire stale unacknowledged packets older than this age.",default:12e4,minimum:1e3,maximum:3e5},retransmitMinAge:{type:"number",title:"Retransmit Min Age (ms)",description:"Minimum packet age before expiration is allowed.",default:1e4,minimum:200,maximum:3e4},retransmitRttMultiplier:{type:"number",title:"RTT Expiry Multiplier",description:"Dynamic expiry age = RTT × this multiplier.",default:12,minimum:2,maximum:20},ackIdleDrainAge:{type:"number",title:"ACK Idle Drain Age (ms)",description:"If ACKs are idle longer than this, expiry becomes more aggressive.",default:2e4,minimum:500,maximum:3e4},forceDrainAfterAckIdle:{type:"boolean",title:"Force Drain After ACK Idle",description:"When enabled, clear retransmit queue if no ACKs arrive for too long.",default:!1},forceDrainAfterMs:{type:"number",title:"Force Drain Timeout (ms)",description:"ACK idle duration before force-draining retransmit queue to zero.",default:45e3,minimum:2e3,maximum:12e4},recoveryBurstEnabled:{type:"boolean",title:"Recovery Burst Enabled",description:"When ACKs return after outage, rapidly retransmit queued packets to catch up.",default:!0},recoveryBurstSize:{type:"number",title:"Recovery Burst Size",description:"Max queued packets to retransmit per recovery burst cycle.",default:100,minimum:10,maximum:1e3},recoveryBurstIntervalMs:{type:"number",title:"Recovery Burst Interval (ms)",description:"Interval between recovery burst cycles while backlog exists.",default:200,minimum:50,maximum:5e3},recoveryAckGapMs:{type:"number",title:"Recovery ACK Gap (ms)",description:"Minimum ACK silence before triggering fast recovery bursts.",default:4e3,minimum:500,maximum:12e4}}},p={type:"object",title:"Reliability Settings (v2/v3 only)",description:"Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery.",properties:{ackInterval:{type:"number",title:"ACK Interval (ms)",description:"How often server sends cumulative ACK updates.",default:100,minimum:20,maximum:5e3},ackResendInterval:{type:"number",title:"ACK Resend Interval (ms)",description:"Re-send duplicate ACK periodically to recover from lost ACK packets.",default:1e3,minimum:100,maximum:1e4},nakTimeout:{type:"number",title:"NAK Timeout (ms)",description:"Delay before requesting retransmission for missing sequence numbers.",default:100,minimum:20,maximum:5e3}}},g={type:"object",title:"Dynamic Congestion Control (v2/v3 only)",description:"Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions.",properties:{enabled:{type:"boolean",title:"Enable Congestion Control",description:"Automatically adjust delta timer based on RTT and packet loss.",default:!1},targetRTT:{type:"number",title:"Target RTT (ms)",description:"RTT threshold above which send rate is reduced.",default:200,minimum:50,maximum:2e3},nominalDeltaTimer:{type:"number",title:"Nominal Delta Timer (ms)",description:"Preferred steady-state send interval.",default:1e3,minimum:100,maximum:1e4},minDeltaTimer:{type:"number",title:"Minimum Delta Timer (ms)",description:"Fastest allowed send interval.",default:100,minimum:50,maximum:1e3},maxDeltaTimer:{type:"number",title:"Maximum Delta Timer (ms)",description:"Slowest allowed send interval.",default:5e3,minimum:1e3,maximum:3e4}}},f={type:"object",title:"Connection Bonding (v2/v3 only)",description:"Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections.",properties:{enabled:{type:"boolean",title:"Enable Connection Bonding",description:"Enable dual-link bonding with automatic failover.",default:!1},mode:{type:"string",title:"Bonding Mode",description:"Bonding operating mode.",default:"main-backup",oneOf:[{const:"main-backup",title:"Main/Backup – Failover to backup when primary degrades"}]},primary:{type:"object",title:"Primary Link",description:"Primary connection (e.g. LTE modem).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4446,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},backup:{type:"object",title:"Backup Link",description:"Backup connection (e.g. Starlink, satellite).",properties:{address:{type:"string",title:"Server Address",default:"127.0.0.1"},port:{type:"number",title:"UDP Port",default:4447,minimum:1024,maximum:65535},interface:{type:"string",title:"Bind Interface (optional)",description:"Network interface IP to bind to."}}},failover:{type:"object",title:"Failover Thresholds",description:"Configure when failover is triggered.",properties:{rttThreshold:{type:"number",title:"RTT Threshold (ms)",default:500,minimum:100,maximum:5e3},lossThreshold:{type:"number",title:"Packet Loss Threshold (0-1)",default:.1,minimum:.01,maximum:.5},healthCheckInterval:{type:"number",title:"Health Check Interval (ms)",default:1e3,minimum:500,maximum:1e4},failbackDelay:{type:"number",title:"Failback Delay (ms)",default:3e4,minimum:5e3,maximum:3e5},heartbeatTimeout:{type:"number",title:"Heartbeat Timeout (ms)",default:5e3,minimum:1e3,maximum:3e4}}}}},y={type:"boolean",title:"Enable Signal K Notifications",description:"Emit Signal K notifications for alerts and failover events.",default:!1},b={type:"object",title:"Monitoring Alert Thresholds (v2/v3 only)",description:"Customize warning/critical thresholds for network monitoring alerts.",properties:{rtt:{type:"object",title:"RTT Thresholds",properties:{warning:{type:"number",title:"Warning RTT (ms)",default:300},critical:{type:"number",title:"Critical RTT (ms)",default:800}}},packetLoss:{type:"object",title:"Packet Loss Thresholds",properties:{warning:{type:"number",title:"Warning Loss Ratio",default:.03},critical:{type:"number",title:"Critical Loss Ratio",default:.1}}},retransmitRate:{type:"object",title:"Retransmit Rate Thresholds",properties:{warning:{type:"number",title:"Warning Retransmit Ratio",default:.05},critical:{type:"number",title:"Critical Retransmit Ratio",default:.15}}},jitter:{type:"object",title:"Jitter Thresholds",properties:{warning:{type:"number",title:"Warning Jitter (ms)",default:100},critical:{type:"number",title:"Critical Jitter (ms)",default:300}}},queueDepth:{type:"object",title:"Queue Depth Thresholds",properties:{warning:{type:"number",title:"Warning Queue Depth",default:100},critical:{type:"number",title:"Critical Queue Depth",default:500}}}}};function h(e,t){const n=Number(t)>=2,r={...d},i=["serverType","udpPort","secretKey"];return e?(Object.assign(r,m),r.enableNotifications=y,i.push("udpAddress","testAddress","testPort"),n&&(r.reliability=u,r.congestionControl=g,r.bonding=f,r.alertThresholds=b)):n&&(r.reliability=p),{type:"object",required:i,properties:r}}const k="/plugins/signalk-edge-link";let v=0;function x(){return`skel-${Date.now()}-${++v}`}function T(e){return{_id:x(),name:e||"client",serverType:"client",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,enableNotifications:!1,protocolVersion:1,udpAddress:"127.0.0.1",helloMessageSender:60,testAddress:"127.0.0.1",testPort:80,pingIntervalTime:1}}function A(e){return{_id:x(),name:e||"server",serverType:"server",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,protocolVersion:1}}function w(e){return e._id?e:{...e,_id:x()}}function E(e){const t=h("server"!==e.serverType,e.protocolVersion),{_id:n,...r}=e;return{...(0,o.NV)(a.Ay,t,r),_id:n}}function S(e){if(null===e||"object"!=typeof e)return JSON.stringify(e);if(Array.isArray(e))return"["+e.map(S).join(",")+"]";const t=e;return"{"+Object.keys(t).sort().map(e=>JSON.stringify(e)+":"+S(t[e])).join(",")+"}"}const C={"ui:order":["name","serverType","udpAddress","udpPort","secretKey","stretchAsciiKey","protocolVersion","useMsgpack","usePathDictionary","testAddress","testPort","pingIntervalTime","helloMessageSender","heartbeatInterval","reliability","congestionControl","bonding","enableNotifications","alertThresholds"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"},reliability:{"ui:classNames":"skel-optional-group"},congestionControl:{"ui:classNames":"skel-optional-group"},bonding:{"ui:classNames":"skel-optional-group"},alertThresholds:{"ui:classNames":"skel-optional-group"}},P={"ui:order":["name","serverType","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion","reliability"],secretKey:{"ui:widget":"password","ui:help":"Use 32-character ASCII, 64-character hex, or 44-character base64"},stretchAsciiKey:{"ui:help":"Only applies to 32-char ASCII keys. Must match on both peers."},serverType:{"ui:widget":"select"}},N=["name","udpPort","secretKey","stretchAsciiKey","useMsgpack","usePathDictionary","protocolVersion"];function I({conn:e,index:t,totalCount:n,expanded:o,onToggle:s,onChange:l,onRemove:c}){const d="server"!==e.serverType,m=h(d,e.protocolVersion),u=d?C:P,p=d?"Client":"Server",g=(e.name||`Connection ${t+1}`).trim(),{_id:f,...y}=e;return r.createElement("div",{className:"skel-card"},r.createElement("div",{className:"skel-card-header",onClick:s,role:"button","aria-expanded":o},r.createElement("span",{className:"skel-badge "+(d?"skel-badge-client":"skel-badge-server")},p),r.createElement("span",{className:"skel-card-title"},g),r.createElement("span",{className:"skel-expand-icon"},o?"▲":"▼"),r.createElement("button",{className:"skel-btn-remove",disabled:n<=1,onClick:e=>{e.stopPropagation(),c()},title:n<=1?"Cannot remove the only connection":"Remove this connection"},"Remove")),o&&r.createElement("div",{className:"skel-card-body"},r.createElement(i.Ay,{schema:m,uiSchema:u,formData:y,validator:a.Ay,onChange:function(t){const n=t.formData;if(n.serverType!==e.serverType){const t={..."server"===n.serverType?A(n.name):T(n.name),_id:e._id};for(const e of N)void 0!==n[e]&&(t[e]=n[e]);return t.serverType=n.serverType,void l(t)}const r={...n,_id:e._id},{_id:i,...a}=r,{_id:o,...s}=e;(function(e,t){const n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(const r of n){if(!Object.prototype.hasOwnProperty.call(t,r))return!1;const n=e[r],i=t[r];if(n!==i){if(null===n||null===i||"object"!=typeof n||"object"!=typeof i)return!1;if(S(n)!==S(i))return!1}}return!0})(a,s)||l(r)},onSubmit:()=>{},liveValidate:!1},r.createElement("div",null))))}const K=function(e){const[t,n]=(0,r.useState)([]),[i,a]=(0,r.useState)(""),[o,l]=(0,r.useState)(!1),[d,m]=(0,r.useState)(!0),[u,p]=(0,r.useState)(null),[g,f]=(0,r.useState)(null),[y,b]=(0,r.useState)(null),[h,v]=(0,r.useState)(0),[x,S]=(0,r.useState)(!1),C=(0,r.useRef)(!1);(0,r.useEffect)(()=>{!async function(){try{const e=await c(`${k}/plugin-config`);if(401===e.status)throw new Error(s);if(!e.ok)throw new Error(`HTTP ${e.status}: ${e.statusText}`);const t=await e.json();if(!t.success)throw new Error(t.error||"Failed to load configuration");const r=t.configuration||{};let i;i=Array.isArray(r.connections)&&r.connections.length>0?r.connections.map(e=>E(w(e))):r.serverType?[E(w(r))]:[T()],n(i),a("string"==typeof r.managementApiToken?r.managementApiToken:""),l(!0===r.requireManagementApiToken),v(0),S(!1)}catch(e){p(e instanceof Error?e.message:String(e))}finally{m(!1)}}()},[]);const P=t.filter(e=>"server"===e.serverType).map(e=>e.udpPort),N=new Set(P.filter((e,t)=>P.indexOf(e)!==t));function K(){S(!0),f(null),b(null)}const D=(0,r.useCallback)(async()=>{if(!C.current){if(0===t.length)return b("At least one connection is required before saving."),void f({type:"error",message:"Cannot save an empty configuration. Add at least one connection."});if(b(null),N.size>0)f({type:"error",message:`Duplicate server ports detected: ${[...N].join(", ")}. Each server must use a unique UDP port.`});else{C.current=!0,f({type:"saving",message:"Saving configuration..."});try{const e=t.map(({_id:e,...t})=>t),n=await c(`${k}/plugin-config`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({connections:e,managementApiToken:i,requireManagementApiToken:o})});if(401===n.status)throw new Error(s);const r=await n.json();if(!n.ok||!r.success)throw new Error(r.error||"Failed to save");f({type:"success",message:r.message||"Configuration saved. Plugin restarting..."}),S(!1)}catch(e){f({type:"error",message:e instanceof Error?e.message:String(e)})}finally{C.current=!1}}}},[t,N,i,o]);return d?r.createElement("div",{style:{padding:"20px",textAlign:"center"}},"Loading configuration..."):u?r.createElement("div",{style:{padding:"20px"}},r.createElement("div",{className:"skel-alert skel-alert-error"},r.createElement("strong",null,"Error loading configuration:")," ",u)):r.createElement("div",{className:"skel-config"},r.createElement("style",null,'\n.skel-config { font-family: inherit; }\n.skel-dirty-banner {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: #fff3cd;\n color: #664d03;\n border: 1px solid #ffe69c;\n border-radius: 4px;\n margin-bottom: 12px;\n font-size: 0.88rem;\n}\n.skel-card {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 12px;\n overflow: hidden;\n}\n.skel-card-header {\n display: flex;\n align-items: center;\n padding: 10px 14px;\n background: #f8f9fa;\n cursor: pointer;\n user-select: none;\n gap: 10px;\n}\n.skel-card-header:hover { background: #e9ecef; }\n.skel-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 12px;\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n}\n.skel-badge-server { background: #cfe2ff; color: #084298; }\n.skel-badge-client { background: #d1e7dd; color: #0a3622; }\n.skel-card-title { font-weight: 600; flex: 1; }\n.skel-expand-icon { font-size: 0.8rem; color: #6c757d; }\n.skel-btn-remove {\n background: none;\n border: 1px solid #dc3545;\n color: #dc3545;\n border-radius: 4px;\n padding: 2px 8px;\n font-size: 0.8rem;\n cursor: pointer;\n}\n.skel-btn-remove:hover { background: #dc3545; color: white; }\n.skel-btn-remove:disabled { opacity: 0.4; cursor: default; border-color: #aaa; color: #aaa; }\n.skel-btn-remove:disabled:hover { background: none; }\n.skel-card-body { padding: 16px; border-top: 1px solid #dee2e6; }\n.skel-toolbar {\n display: flex;\n gap: 10px;\n align-items: center;\n margin-top: 16px;\n padding-top: 16px;\n border-top: 1px solid #dee2e6;\n flex-wrap: wrap;\n}\n.skel-btn {\n padding: 7px 16px;\n border-radius: 4px;\n font-size: 0.95rem;\n cursor: pointer;\n border: none;\n}\n.skel-btn-primary { background: #0d6efd; color: white; }\n.skel-btn-primary:hover { background: #0b5ed7; }\n.skel-btn-primary:disabled { background: #6c757d; cursor: default; }\n.skel-btn-secondary { background: white; color: #0d6efd; border: 1px solid #0d6efd; }\n.skel-btn-secondary:hover { background: #e7f0ff; }\n.skel-alert {\n padding: 10px 14px;\n border-radius: 4px;\n margin-bottom: 14px;\n font-size: 0.9rem;\n}\n.skel-alert-success { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }\n.skel-alert-error { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }\n.skel-alert-saving { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }\n.skel-dup-warn { font-size: 0.8rem; color: #dc3545; margin-top: 4px; }\n.skel-plugin-settings {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n margin-bottom: 20px;\n padding: 16px;\n background: #f8f9fa;\n}\n.skel-plugin-settings h3 {\n margin: 0 0 12px;\n font-size: 1rem;\n font-weight: 600;\n}\n.skel-field-group {\n margin-bottom: 14px;\n}\n.skel-field-group label {\n display: block;\n font-weight: 500;\n margin-bottom: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="text"],\n.skel-field-group input[type="password"] {\n width: 100%;\n max-width: 420px;\n padding: 6px 10px;\n border: 1px solid #ced4da;\n border-radius: 4px;\n font-size: 0.9rem;\n}\n.skel-field-group input[type="checkbox"] {\n margin-right: 6px;\n}\n.skel-field-desc {\n font-size: 0.8rem;\n color: #5c6773;\n margin-top: 3px;\n}\n.skel-config .field-description {\n color: #5c6773;\n font-size: 0.83rem;\n line-height: 1.35;\n}\n.skel-config legend,\n.skel-config label {\n line-height: 1.2;\n overflow-wrap: anywhere;\n}\n.skel-optional-group {\n margin-top: 12px;\n border: 1px dashed #ccd5df;\n border-radius: 6px;\n padding: 10px 12px 4px;\n background: #fbfcfe;\n}\n.skel-optional-group legend {\n font-size: 0.92rem;\n margin-bottom: 6px;\n}\n.skel-optional-group .form-group {\n margin-bottom: 10px;\n}\n.skel-optional-group .form-control {\n max-width: 340px;\n}\n'),x&&"saving"!==g?.type&&r.createElement("div",{className:"skel-dirty-banner"},r.createElement("span",null,"⚠"),r.createElement("span",null,"You have unsaved changes.")),g&&r.createElement("div",{className:"skel-alert skel-alert-"+("saving"===g.type?"saving":"success"===g.type?"success":"error")},g.message),r.createElement("div",{className:"skel-plugin-settings"},r.createElement("h3",null,"Plugin Security Settings"),r.createElement("div",{className:"skel-field-group"},r.createElement("label",{htmlFor:"skel-mgmt-token"},"Management API Token"),r.createElement("input",{id:"skel-mgmt-token",type:"password",value:i,placeholder:"Leave empty for open access",onChange:e=>{a(e.target.value),K()},autoComplete:"new-password"}),r.createElement("div",{className:"skel-field-desc"},"Shared secret to protect the management API endpoints. Strongly recommended for production. Can also be set via the"," ",r.createElement("code",null,"SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN")," environment variable (env var takes priority). Leave empty to allow open access.")),r.createElement("div",{className:"skel-field-group"},r.createElement("label",null,r.createElement("input",{type:"checkbox",checked:o,onChange:e=>{l(e.target.checked),K()}}),"Require Management API Token"),r.createElement("div",{className:"skel-field-desc"},"When enabled, all management API requests are rejected if no token is configured (fail-closed). When disabled, requests are allowed if no token is set (open access)."))),t.map((e,i)=>r.createElement("div",{key:e._id},r.createElement(I,{conn:e,index:i,totalCount:t.length,expanded:h===i,onToggle:()=>function(e){v(t=>t===e?null:e)}(i),onChange:e=>function(e,t){n(n=>n.map((n,r)=>r===e?t:n)),K()}(i,e),onRemove:()=>function(e){n(t=>{if(t.length<=1)return t;const n=t.filter((t,n)=>n!==e);return v(t=>null!==t&&t>=e&&t>0?t-1:t),n}),K()}(i)}),"server"===e.serverType&&N.has(e.udpPort)&&r.createElement("div",{className:"skel-dup-warn"},"Port ",e.udpPort," is used by multiple server connections. Each server requires a unique port."))),r.createElement("div",{className:"skel-toolbar"},r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,A(`server-${e.length+1}`)];return v(t.length-1),t}),K()}},"+ Add Server"),r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,T(`client-${e.length+1}`)];return v(t.length-1),t}),K()}},"+ Add Client"),r.createElement("button",{className:"skel-btn skel-btn-primary",onClick:D,disabled:g&&"saving"===g.type||0===t.length},x?"Save Changes":"Save Configuration"),y&&r.createElement("span",{style:{color:"#dc3545",fontSize:"0.85rem",fontWeight:500}},y),r.createElement("span",{style:{fontSize:"0.85rem",color:"#6c757d"}},t.length," connection",1!==t.length?"s":""," · ",t.filter(e=>"server"===e.serverType).length," server",1!==t.filter(e=>"server"===e.serverType).length?"s":"",", ",t.filter(e=>"server"!==e.serverType).length," client",1!==t.filter(e=>"server"!==e.serverType).length?"s":"")))}}}]);
|
|
2
|
+
//# sourceMappingURL=982.63949a2b2f6c5854e034.js.map
|