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/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
- return { packCrypt, unpackDecrypt };
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)) {
@@ -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") : "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,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-edge-link",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
@@ -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.b207a377ed6542e2fb4a.js.map
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