signalk-edge-link 2.3.0 → 2.4.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.
@@ -18,6 +18,7 @@ exports.VALID_CONNECTION_KEYS = [
18
18
  "useMsgpack",
19
19
  "usePathDictionary",
20
20
  "enableNotifications",
21
+ "skipOwnData",
21
22
  "protocolVersion",
22
23
  "udpAddress",
23
24
  "helloMessageSender",
@@ -75,6 +76,9 @@ function validateConnectionConfig(connection, prefix = "") {
75
76
  if (conn.alertThresholds !== undefined) {
76
77
  return `${p}alertThresholds is not supported in server mode`;
77
78
  }
79
+ if (conn.skipOwnData !== undefined) {
80
+ return `${p}skipOwnData is not supported in server mode`;
81
+ }
78
82
  }
79
83
  if (!isValidPort(conn.udpPort, 1024)) {
80
84
  return `${p}udpPort must be an integer between 1024 and 65535`;
@@ -103,6 +107,9 @@ function validateConnectionConfig(connection, prefix = "") {
103
107
  if (conn.enableNotifications !== undefined && typeof conn.enableNotifications !== "boolean") {
104
108
  return `${p}enableNotifications must be a boolean`;
105
109
  }
110
+ if (conn.skipOwnData !== undefined && typeof conn.skipOwnData !== "boolean") {
111
+ return `${p}skipOwnData must be a boolean`;
112
+ }
106
113
  if (conn.name !== undefined &&
107
114
  (typeof conn.name !== "string" || conn.name.length > 40)) {
108
115
  return `${p}name must be a string of at most 40 characters`;
@@ -328,6 +335,7 @@ function sanitizeConnectionConfig(connection) {
328
335
  delete out.congestionControl;
329
336
  delete out.bonding;
330
337
  delete out.alertThresholds;
338
+ delete out.skipOwnData;
331
339
  }
332
340
  return out;
333
341
  }
@@ -1,7 +1,92 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stripOwnDataFromDelta = stripOwnDataFromDelta;
3
4
  exports.sanitizeDeltaForSignalK = sanitizeDeltaForSignalK;
4
5
  exports.sanitizeDeltaPayloadForSignalK = sanitizeDeltaPayloadForSignalK;
6
+ /**
7
+ * Path prefixes for data this plugin publishes locally. When the
8
+ * `skipOwnData` option is set on a client connection, value entries with
9
+ * matching paths are stripped before the delta is forwarded over the link so
10
+ * the receiver's Signal K tree is not polluted with the sender's own
11
+ * edge-link metrics. The `networking.edgeLink.*` subtree is owned entirely
12
+ * by this plugin so the whole prefix is matched.
13
+ */
14
+ const OWN_DATA_PATH_PREFIXES = ["networking.edgeLink."];
15
+ /**
16
+ * RTT paths the plugin publishes — kept by `stripOwnDataFromDelta` even when
17
+ * `skipOwnData` is on, because operators rely on RTT for link-health
18
+ * visibility on both sides of the link. Covers v1 modem RTT
19
+ * (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`) and v2
20
+ * edge-link RTT (`networking.edgeLink.rtt`,
21
+ * `networking.edgeLink.<instanceId>.rtt`).
22
+ */
23
+ const RTT_PATH_RE = /^networking\.(?:modem|edgeLink)(?:\.[^.]+)?\.rtt$/;
24
+ function isOwnDataPath(path) {
25
+ if (typeof path !== "string") {
26
+ return false;
27
+ }
28
+ // RTT paths (modem + edgeLink, namespaced or not) are always forwarded so
29
+ // the receiver retains link-health visibility regardless of skipOwnData.
30
+ if (RTT_PATH_RE.test(path)) {
31
+ return false;
32
+ }
33
+ for (const prefix of OWN_DATA_PATH_PREFIXES) {
34
+ // prefix.slice(0, -1) drops the trailing ".", so a published path that
35
+ // matches the prefix root exactly (e.g. just "networking.edgeLink") still
36
+ // counts as own data; startsWith(prefix) covers everything underneath.
37
+ if (path === prefix.slice(0, -1) || path.startsWith(prefix)) {
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * Drop value/meta entries whose paths are owned by this plugin. Returns null
45
+ * when nothing remains to forward. Updates that become empty are dropped; the
46
+ * delta is dropped entirely when no updates survive.
47
+ */
48
+ function stripOwnDataFromDelta(delta) {
49
+ if (!delta || !Array.isArray(delta.updates)) {
50
+ return null;
51
+ }
52
+ let changed = false;
53
+ const surviving = [];
54
+ for (const update of delta.updates) {
55
+ const rawValues = Array.isArray(update.values) ? update.values : [];
56
+ const values = rawValues.filter((v) => !isOwnDataPath(v?.path));
57
+ const valuesChanged = values.length !== rawValues.length;
58
+ const rawMeta = Array.isArray(update.meta) ? update.meta : null;
59
+ const meta = rawMeta
60
+ ? rawMeta.filter((m) => !isOwnDataPath(m?.path))
61
+ : null;
62
+ const metaChanged = rawMeta !== null && meta !== null && meta.length !== rawMeta.length;
63
+ if (values.length === 0 && (!meta || meta.length === 0)) {
64
+ changed = true;
65
+ continue;
66
+ }
67
+ if (valuesChanged || metaChanged) {
68
+ changed = true;
69
+ const next = { ...update, values };
70
+ if (meta && meta.length > 0) {
71
+ next.meta = meta;
72
+ }
73
+ else if (rawMeta) {
74
+ delete next.meta;
75
+ }
76
+ surviving.push(next);
77
+ }
78
+ else {
79
+ surviving.push(update);
80
+ }
81
+ }
82
+ if (surviving.length === 0) {
83
+ return null;
84
+ }
85
+ if (!changed) {
86
+ return delta;
87
+ }
88
+ return { ...delta, updates: surviving };
89
+ }
5
90
  function isObject(value) {
6
91
  return value !== null && typeof value === "object" && !Array.isArray(value);
7
92
  }
package/lib/instance.js CHANGED
@@ -205,10 +205,25 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
205
205
  });
206
206
  /**
207
207
  * Forward subscribed deltas as-is except for malformed value entries that
208
- * Signal K would reject on the receiver side.
208
+ * Signal K would reject on the receiver side. When `skipOwnData` is set on
209
+ * a client connection, also drop value/meta entries this plugin publishes
210
+ * locally under the `networking.edgeLink.*` subtree, so the receiver's
211
+ * Signal K tree is not polluted with the sender's own edge-link metrics.
212
+ *
213
+ * Exception: RTT paths are always forwarded regardless of skipOwnData so
214
+ * the operator retains link-health visibility on both sides of the link.
215
+ * The carve-out covers both v2 edge-link RTT
216
+ * (`networking.edgeLink.rtt`, `networking.edgeLink.<instanceId>.rtt`) and
217
+ * the v1 modem RTT paths historically published by `publishRtt`
218
+ * (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`). See
219
+ * `stripOwnDataFromDelta` in `delta-sanitizer.ts` for the implementation.
209
220
  */
210
221
  function filterOutboundDelta(delta) {
211
- return (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
222
+ const sanitized = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
223
+ if (!sanitized || !options.skipOwnData) {
224
+ return sanitized;
225
+ }
226
+ return (0, delta_sanitizer_1.stripOwnDataFromDelta)(sanitized);
212
227
  }
213
228
  // ── Metadata streaming ────────────────────────────────────────────────────
214
229
  /** In-memory cache of last-sent meta (hashed) per context+path. Used to
@@ -775,7 +775,8 @@ function createPipelineV2Client(app, state, metricsApi) {
775
775
  });
776
776
  }
777
777
  }
778
- // Send client-side telemetry to the server
778
+ // RTT is always published operators rely on it for link-health visibility
779
+ // even when skipOwnData suppresses the rest of edge-link's own metrics.
779
780
  if (!telemetrySendInFlight &&
780
781
  state.readyToSend &&
781
782
  state.options &&
@@ -783,6 +784,23 @@ function createPipelineV2Client(app, state, metricsApi) {
783
784
  state.options.secretKey &&
784
785
  state.options.udpAddress &&
785
786
  state.options.udpPort) {
787
+ const rttValues = [{ path: "networking.edgeLink.rtt", value: metrics.rtt || 0 }];
788
+ const extraValues = state.options.skipOwnData
789
+ ? []
790
+ : [
791
+ { path: "networking.edgeLink.jitter", value: metrics.jitter || 0 },
792
+ { path: "networking.edgeLink.packetLoss", value: packetLoss },
793
+ {
794
+ path: "networking.edgeLink.retransmissions",
795
+ value: metrics.retransmissions || 0
796
+ },
797
+ { path: "networking.edgeLink.queueDepth", value: retransmitQueue.getSize() },
798
+ { path: "networking.edgeLink.retransmitRate", value: retransmitRate },
799
+ {
800
+ path: "networking.edgeLink.activeLink",
801
+ value: bondingManager ? bondingManager.getActiveLinkName() : "primary"
802
+ }
803
+ ];
786
804
  const telemetryDelta = {
787
805
  context: "vessels.self",
788
806
  updates: [
@@ -792,21 +810,7 @@ function createPipelineV2Client(app, state, metricsApi) {
792
810
  type: "plugin"
793
811
  },
794
812
  timestamp: new Date().toISOString(),
795
- values: [
796
- { path: "networking.edgeLink.rtt", value: metrics.rtt || 0 },
797
- { path: "networking.edgeLink.jitter", value: metrics.jitter || 0 },
798
- { path: "networking.edgeLink.packetLoss", value: packetLoss },
799
- {
800
- path: "networking.edgeLink.retransmissions",
801
- value: metrics.retransmissions || 0
802
- },
803
- { path: "networking.edgeLink.queueDepth", value: retransmitQueue.getSize() },
804
- { path: "networking.edgeLink.retransmitRate", value: retransmitRate },
805
- {
806
- path: "networking.edgeLink.activeLink",
807
- value: bondingManager ? bondingManager.getActiveLinkName() : "primary"
808
- }
809
- ]
813
+ values: [...rttValues, ...extraValues]
810
814
  }
811
815
  ]
812
816
  };
@@ -15,7 +15,7 @@
15
15
  * results to `RJSFSchema` at call sites.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.alertThresholdsProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.commonConnectionProperties = void 0;
18
+ exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.commonConnectionProperties = void 0;
19
19
  exports.buildConnectionItemSchema = buildConnectionItemSchema;
20
20
  exports.buildWebappConnectionSchema = buildWebappConnectionSchema;
21
21
  const crypto_constants_1 = require("./crypto-constants");
@@ -424,6 +424,13 @@ exports.enableNotificationsProperty = {
424
424
  description: "Emit Signal K notifications for alerts and failover events.",
425
425
  default: false
426
426
  };
427
+ // ── Client-only: skip forwarding plugin-generated data ────────────────────────
428
+ exports.skipOwnDataProperty = {
429
+ type: "boolean",
430
+ title: "Skip Plugin's Own Data",
431
+ description: "Do not forward data this plugin publishes locally over the link. Strips entries under 'networking.edgeLink.*' and the v1 RTT path 'networking.modem.rtt' / 'networking.modem.<id>.rtt'; other 'networking.modem.*' paths from external providers are left intact. Also suppresses the v2/v3 client telemetry packet that mirrors local link metrics to the receiver.",
432
+ default: false
433
+ };
427
434
  // ── v2/v3 monitoring alert thresholds (client) ────────────────────────────────
428
435
  exports.alertThresholdsProperty = {
429
436
  type: "object",
@@ -501,6 +508,7 @@ function buildConnectionItemSchema() {
501
508
  congestionControl: exports.congestionControlProperty,
502
509
  bonding: exports.bondingProperty,
503
510
  enableNotifications: exports.enableNotificationsProperty,
511
+ skipOwnData: exports.skipOwnDataProperty,
504
512
  alertThresholds: exports.alertThresholdsProperty
505
513
  },
506
514
  required: ["udpAddress", "testAddress", "testPort"]
@@ -524,6 +532,7 @@ function buildWebappConnectionSchema(isClient, protocolVersion) {
524
532
  if (isClient) {
525
533
  Object.assign(props, exports.clientTransportProperties);
526
534
  props.enableNotifications = exports.enableNotificationsProperty;
535
+ props.skipOwnData = exports.skipOwnDataProperty;
527
536
  required.push("udpAddress", "testAddress", "testPort");
528
537
  if (isReliableProtocol) {
529
538
  props.reliability = exports.clientReliabilityProperty;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-edge-link",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
@@ -0,0 +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),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:"boolean",title:"Skip Plugin's Own Data",description:"Do not forward data this plugin publishes locally over the link. Strips entries under 'networking.edgeLink.*' and the v1 RTT path 'networking.modem.rtt' / 'networking.modem.<id>.rtt'; other 'networking.modem.*' paths from external providers are left intact. Also suppresses the v2/v3 client telemetry packet that mirrors local link metrics to the receiver.",default:!1},h={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 k(e,t){const n=Number(t)>=2,r={...d},i=["serverType","udpPort","secretKey"];return e?(Object.assign(r,m),r.enableNotifications=y,r.skipOwnData=b,i.push("udpAddress","testAddress","testPort"),n&&(r.reliability=u,r.congestionControl=g,r.bonding=f,r.alertThresholds=h)):n&&(r.reliability=p),{type:"object",required:i,properties:r}}const v="/plugins/signalk-edge-link";let x=0;function T(){return`skel-${Date.now()}-${++x}`}function w(e){return{_id:T(),name:e||"client",serverType:"client",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,enableNotifications:!1,skipOwnData:!1,protocolVersion:1,udpAddress:"127.0.0.1",helloMessageSender:60,testAddress:"127.0.0.1",testPort:80,pingIntervalTime:1}}function A(e){return{_id:T(),name:e||"server",serverType:"server",udpPort:4446,secretKey:"",stretchAsciiKey:!1,useMsgpack:!1,usePathDictionary:!1,protocolVersion:1}}function S(e){return e._id?e:{...e,_id:T()}}function E(e){const t=k("server"!==e.serverType,e.protocolVersion),{_id:n,...r}=e;return{...(0,o.NV)(a.Ay,t,r),_id:n}}function C(e){if(null===e||"object"!=typeof e)return JSON.stringify(e);if(Array.isArray(e))return"["+e.map(C).join(",")+"]";const t=e;return"{"+Object.keys(t).sort().map(e=>JSON.stringify(e)+":"+C(t[e])).join(",")+"}"}const P={"ui:order":["name","serverType","udpAddress","udpPort","secretKey","stretchAsciiKey","protocolVersion","useMsgpack","usePathDictionary","testAddress","testPort","pingIntervalTime","helloMessageSender","heartbeatInterval","reliability","congestionControl","bonding","skipOwnData","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"}},N={"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"}},D=["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=k(d,e.protocolVersion),u=d?P:N,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):w(n.name),_id:e._id};for(const e of D)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(C(n)!==C(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,k]=(0,r.useState)(0),[x,T]=(0,r.useState)(!1),C=(0,r.useRef)(!1);(0,r.useEffect)(()=>{!async function(){try{const e=await c(`${v}/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(S(e))):r.serverType?[E(S(r))]:[w()],n(i),a("string"==typeof r.managementApiToken?r.managementApiToken:""),l(!0===r.requireManagementApiToken),k(0),T(!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 D(){T(!0),f(null),b(null)}const K=(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(`${v}/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..."}),T(!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),D()},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),D()}}),"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){k(t=>t===e?null:e)}(i),onChange:e=>function(e,t){n(n=>n.map((n,r)=>r===e?t:n)),D()}(i,e),onRemove:()=>function(e){n(t=>{if(t.length<=1)return t;const n=t.filter((t,n)=>n!==e);return k(t=>null!==t&&t>=e&&t>0?t-1:t),n}),D()}(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 k(t.length-1),t}),D()}},"+ Add Server"),r.createElement("button",{className:"skel-btn skel-btn-secondary",onClick:function(){n(e=>{const t=[...e,w(`client-${e.length+1}`)];return k(t.length-1),t}),D()}},"+ Add Client"),r.createElement("button",{className:"skel-btn skel-btn-primary",onClick:K,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.cc4f5aca99be921e0171.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"982.cc4f5aca99be921e0171.js","mappings":"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","sources":["webpack://signalk-edge-link/./src/webapp/utils/apiFetch.ts","webpack://signalk-edge-link/./src/shared/crypto-constants.ts","webpack://signalk-edge-link/./src/shared/connection-schema.ts","webpack://signalk-edge-link/./src/webapp/components/PluginConfigurationPanel.tsx"],"sourcesContent":["/// <reference lib=\"dom\" />\r\n\r\nexport const MANAGEMENT_TOKEN_ERROR_MESSAGE = \"Management token required/invalid.\";\r\n\r\ninterface AuthConfig {\r\n token: string | null;\r\n localStorageKey: string;\r\n queryParam: string;\r\n includeTokenInQuery: boolean;\r\n headerMode: string;\r\n}\r\n\r\ndeclare global {\r\n interface Window {\r\n __EDGE_LINK_AUTH__?: Partial<AuthConfig>;\r\n }\r\n}\r\n\r\nconst DEFAULT_AUTH_CONFIG: AuthConfig = {\r\n token: null,\r\n localStorageKey: \"signalkEdgeLinkManagementToken\",\r\n queryParam: \"edgeLinkToken\",\r\n // Default to false: query-parameter tokens leak into browser history, server\r\n // access logs, and Referer headers. Set includeTokenInQuery: true in\r\n // window.__EDGE_LINK_AUTH__ only when you explicitly need URL-based auth.\r\n includeTokenInQuery: false,\r\n headerMode: \"both\"\r\n};\r\n\r\nfunction readRuntimeAuthConfig(): AuthConfig {\r\n if (typeof window === \"undefined\") {\r\n return DEFAULT_AUTH_CONFIG;\r\n }\r\n\r\n const runtime = window.__EDGE_LINK_AUTH__;\r\n if (!runtime || typeof runtime !== \"object\") {\r\n return DEFAULT_AUTH_CONFIG;\r\n }\r\n\r\n return { ...DEFAULT_AUTH_CONFIG, ...runtime };\r\n}\r\n\r\nfunction resolveToken(config: AuthConfig): string {\r\n if (config.token) {\r\n return String(config.token).trim();\r\n }\r\n\r\n if (typeof window === \"undefined\") {\r\n return \"\";\r\n }\r\n\r\n // SECURITY NOTE: Query parameter tokens can leak into browser history, server\r\n // access logs, and Referer headers. Prefer localStorage or\r\n // window.__EDGE_LINK_AUTH__.token for production deployments. Set\r\n // includeTokenInQuery: false in __EDGE_LINK_AUTH__ to disable this path.\r\n if (config.includeTokenInQuery && config.queryParam) {\r\n const tokenFromQuery = new URLSearchParams(window.location.search).get(config.queryParam);\r\n if (tokenFromQuery) {\r\n return tokenFromQuery.trim();\r\n }\r\n }\r\n\r\n if (config.localStorageKey && window.localStorage) {\r\n const tokenFromStorage = window.localStorage.getItem(config.localStorageKey);\r\n if (tokenFromStorage) {\r\n return tokenFromStorage.trim();\r\n }\r\n }\r\n\r\n return \"\";\r\n}\r\n\r\nfunction attachAuthHeaders(headers: Headers, token: string, headerMode: string): Headers {\r\n if (!token) {\r\n return headers;\r\n }\r\n\r\n const normalizedMode = (headerMode || \"both\").toLowerCase();\r\n if (\r\n normalizedMode === \"x-edge-link-token\" ||\r\n normalizedMode === \"token\" ||\r\n normalizedMode === \"both\"\r\n ) {\r\n headers.set(\"X-Edge-Link-Token\", token);\r\n }\r\n if (\r\n normalizedMode === \"authorization\" ||\r\n normalizedMode === \"bearer\" ||\r\n normalizedMode === \"both\"\r\n ) {\r\n headers.set(\"Authorization\", `Bearer ${token}`);\r\n }\r\n return headers;\r\n}\r\n\r\nexport function getAuthToken(): string {\r\n const config = readRuntimeAuthConfig();\r\n return resolveToken(config);\r\n}\r\n\r\nexport function getTokenHelpText(): string {\r\n const config = readRuntimeAuthConfig();\r\n const modeText =\r\n config.headerMode && String(config.headerMode).toLowerCase() === \"authorization\"\r\n ? \"Authorization: Bearer <token>\"\r\n : config.headerMode && String(config.headerMode).toLowerCase() === \"x-edge-link-token\"\r\n ? \"X-Edge-Link-Token\"\r\n : \"X-Edge-Link-Token and Authorization: Bearer <token>\";\r\n\r\n return `The server-side token is configured in plugin settings (managementApiToken) or via the SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN environment variable. To authenticate from the browser, provide the token using window.__EDGE_LINK_AUTH__.token, query parameter \"${config.queryParam}\", or localStorage key \"${config.localStorageKey}\". Requests send ${modeText} when a token is available.`;\r\n}\r\n\r\nexport function apiFetch(input: string | Request, init: RequestInit = {}): Promise<Response> {\r\n const config = readRuntimeAuthConfig();\r\n const token = resolveToken(config);\r\n const headers = new Headers(init.headers || {});\r\n attachAuthHeaders(headers, token, config.headerMode);\r\n\r\n return fetch(input, {\r\n ...init,\r\n headers\r\n });\r\n}\r\n","\"use strict\";\r\n\r\n/**\r\n * Shared crypto constants that must stay in sync between the backend crypto\r\n * module and any UI copy that describes key-derivation behaviour. Kept under\r\n * `src/shared/` so both the server-side build and the webapp bundle can\r\n * reference the same value.\r\n */\r\n\r\n/**\r\n * PBKDF2-SHA256 iteration count used by {@link deriveKeyFromPassphrase} and\r\n * by the opt-in 32-char ASCII key stretching path in {@link normalizeKey}.\r\n *\r\n * Tuned to the NIST SP 800-132 recommendation (≥ 600,000) and takes roughly\r\n * ~300 ms on modern server hardware. The derived key is cached per-process\r\n * so the cost is paid at most once per unique (key, salt) pair.\r\n */\r\nexport const PBKDF2_ITERATIONS = 600_000;\r\n","/**\r\n * Single source of truth for the connection configuration schema.\r\n *\r\n * Both the backend `plugin.schema` in `src/index.ts` (used by Signal K's\r\n * default admin UI and served via the `/plugin-schema` route for default\r\n * extraction) and the frontend RJSF form in\r\n * `src/webapp/components/PluginConfigurationPanel.tsx` consume the fragments\r\n * exported here. Adding or editing a connection field must happen in this\r\n * module; the two consumers then render it identically.\r\n *\r\n * The fragments are typed as plain `Record<string, unknown>` so they can be\r\n * imported by both the server-side TypeScript build and the webapp build\r\n * without pulling `@rjsf/utils` into the server bundle. The webapp casts\r\n * results to `RJSFSchema` at call sites.\r\n */\r\n\r\nimport { PBKDF2_ITERATIONS } from \"./crypto-constants\";\r\n\r\nexport type SchemaFragment = Record<string, unknown>;\r\n\r\n// ── Common (client + server) ──────────────────────────────────────────────────\r\n\r\nexport const commonConnectionProperties: Record<string, SchemaFragment> = {\r\n name: {\r\n type: \"string\",\r\n title: \"Connection Name\",\r\n description:\r\n \"Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.\",\r\n default: \"connection\",\r\n maxLength: 40\r\n },\r\n serverType: {\r\n type: \"string\",\r\n title: \"Operation Mode\",\r\n description: \"Select Server to receive data, or Client to send data.\",\r\n default: \"client\",\r\n oneOf: [\r\n { const: \"server\", title: \"Server Mode – Receive Data\" },\r\n { const: \"client\", title: \"Client Mode – Send Data\" }\r\n ]\r\n },\r\n udpPort: {\r\n type: \"number\",\r\n title: \"UDP Port\",\r\n description: \"UDP port for data transmission (must match on both ends).\",\r\n default: 4446,\r\n minimum: 1024,\r\n maximum: 65535\r\n },\r\n secretKey: {\r\n type: \"string\",\r\n title: \"Encryption Key\",\r\n description:\r\n \"32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64.\",\r\n minLength: 32,\r\n maxLength: 64,\r\n pattern: \"^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$\"\r\n },\r\n stretchAsciiKey: {\r\n type: \"boolean\",\r\n title: \"Stretch 32-char ASCII Key (PBKDF2)\",\r\n description: `When the secretKey is 32-character ASCII, route it through PBKDF2-SHA256 (${PBKDF2_ITERATIONS.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.`,\r\n default: false\r\n },\r\n useMsgpack: {\r\n type: \"boolean\",\r\n title: \"Use MessagePack\",\r\n description: \"Binary serialization for smaller payloads (must match on both ends).\",\r\n default: false\r\n },\r\n usePathDictionary: {\r\n type: \"boolean\",\r\n title: \"Use Path Dictionary\",\r\n description: \"Encode paths as numeric IDs for bandwidth savings (must match on both ends).\",\r\n default: false\r\n },\r\n protocolVersion: {\r\n type: \"number\",\r\n title: \"Protocol Version\",\r\n description:\r\n \"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.\",\r\n default: 1,\r\n oneOf: [\r\n { const: 1, title: \"v1 – Standard encrypted UDP\" },\r\n { const: 2, title: \"v2 – Reliability, congestion control, bonding, metrics\" },\r\n { const: 3, title: \"v3 - v2 features with authenticated control packets\" }\r\n ]\r\n }\r\n};\r\n\r\n// ── Client-only transport / reachability fields ───────────────────────────────\r\n\r\nexport const clientTransportProperties: Record<string, SchemaFragment> = {\r\n udpAddress: {\r\n type: \"string\",\r\n title: \"Server Address\",\r\n description: \"IP address or hostname of the remote Signal K endpoint.\",\r\n default: \"127.0.0.1\"\r\n },\r\n helloMessageSender: {\r\n type: \"integer\",\r\n title: \"Heartbeat Interval (seconds)\",\r\n description: \"Send periodic heartbeat messages to keep NAT/firewall mappings alive.\",\r\n default: 60,\r\n minimum: 10,\r\n maximum: 3600\r\n },\r\n testAddress: {\r\n type: \"string\",\r\n title: \"Connectivity Test Address\",\r\n description: \"Host used for reachability checks (e.g. 8.8.8.8).\",\r\n default: \"127.0.0.1\"\r\n },\r\n testPort: {\r\n type: \"number\",\r\n title: \"Connectivity Test Port\",\r\n description: \"Port used for reachability checks (e.g. 53, 80, or 443).\",\r\n default: 80,\r\n minimum: 1,\r\n maximum: 65535\r\n },\r\n pingIntervalTime: {\r\n type: \"number\",\r\n title: \"Check Interval (minutes)\",\r\n description: \"Frequency of network reachability checks.\",\r\n default: 1,\r\n minimum: 0.1,\r\n maximum: 60\r\n },\r\n heartbeatInterval: {\r\n type: \"number\",\r\n title: \"NAT Keepalive Heartbeat Interval (ms)\",\r\n description:\r\n \"v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.\",\r\n default: 25000,\r\n minimum: 5000,\r\n maximum: 120000\r\n }\r\n};\r\n\r\n// ── v2/v3 reliability (client pipeline — retransmit queue) ────────────────────\r\n\r\nexport const clientReliabilityProperty: SchemaFragment = {\r\n type: \"object\",\r\n title: \"Reliability Settings (v2/v3 only)\",\r\n description:\r\n \"Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits.\",\r\n properties: {\r\n retransmitQueueSize: {\r\n type: \"number\",\r\n title: \"Retransmit Queue Size\",\r\n description: \"Maximum number of sent packets stored for potential retransmission.\",\r\n default: 5000,\r\n minimum: 100,\r\n maximum: 50000\r\n },\r\n maxRetransmits: {\r\n type: \"number\",\r\n title: \"Max Retransmit Attempts\",\r\n description: \"Maximum resend attempts before a packet is dropped from the retransmit queue.\",\r\n default: 3,\r\n minimum: 1,\r\n maximum: 20\r\n },\r\n retransmitMaxAge: {\r\n type: \"number\",\r\n title: \"Retransmit Max Age (ms)\",\r\n description: \"Expire stale unacknowledged packets older than this age.\",\r\n default: 120000,\r\n minimum: 1000,\r\n maximum: 300000\r\n },\r\n retransmitMinAge: {\r\n type: \"number\",\r\n title: \"Retransmit Min Age (ms)\",\r\n description: \"Minimum packet age before expiration is allowed.\",\r\n default: 10000,\r\n minimum: 200,\r\n maximum: 30000\r\n },\r\n retransmitRttMultiplier: {\r\n type: \"number\",\r\n title: \"RTT Expiry Multiplier\",\r\n description: \"Dynamic expiry age = RTT × this multiplier.\",\r\n default: 12,\r\n minimum: 2,\r\n maximum: 20\r\n },\r\n ackIdleDrainAge: {\r\n type: \"number\",\r\n title: \"ACK Idle Drain Age (ms)\",\r\n description: \"If ACKs are idle longer than this, expiry becomes more aggressive.\",\r\n default: 20000,\r\n minimum: 500,\r\n maximum: 30000\r\n },\r\n forceDrainAfterAckIdle: {\r\n type: \"boolean\",\r\n title: \"Force Drain After ACK Idle\",\r\n description: \"When enabled, clear retransmit queue if no ACKs arrive for too long.\",\r\n default: false\r\n },\r\n forceDrainAfterMs: {\r\n type: \"number\",\r\n title: \"Force Drain Timeout (ms)\",\r\n description: \"ACK idle duration before force-draining retransmit queue to zero.\",\r\n default: 45000,\r\n minimum: 2000,\r\n maximum: 120000\r\n },\r\n recoveryBurstEnabled: {\r\n type: \"boolean\",\r\n title: \"Recovery Burst Enabled\",\r\n description: \"When ACKs return after outage, rapidly retransmit queued packets to catch up.\",\r\n default: true\r\n },\r\n recoveryBurstSize: {\r\n type: \"number\",\r\n title: \"Recovery Burst Size\",\r\n description: \"Max queued packets to retransmit per recovery burst cycle.\",\r\n default: 100,\r\n minimum: 10,\r\n maximum: 1000\r\n },\r\n recoveryBurstIntervalMs: {\r\n type: \"number\",\r\n title: \"Recovery Burst Interval (ms)\",\r\n description: \"Interval between recovery burst cycles while backlog exists.\",\r\n default: 200,\r\n minimum: 50,\r\n maximum: 5000\r\n },\r\n recoveryAckGapMs: {\r\n type: \"number\",\r\n title: \"Recovery ACK Gap (ms)\",\r\n description: \"Minimum ACK silence before triggering fast recovery bursts.\",\r\n default: 4000,\r\n minimum: 500,\r\n maximum: 120000\r\n }\r\n }\r\n};\r\n\r\n// ── v2/v3 reliability (server pipeline — ACK/NAK timing) ──────────────────────\r\n\r\nexport const serverReliabilityProperty: SchemaFragment = {\r\n type: \"object\",\r\n title: \"Reliability Settings (v2/v3 only)\",\r\n description: \"Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery.\",\r\n properties: {\r\n ackInterval: {\r\n type: \"number\",\r\n title: \"ACK Interval (ms)\",\r\n description: \"How often server sends cumulative ACK updates.\",\r\n default: 100,\r\n minimum: 20,\r\n maximum: 5000\r\n },\r\n ackResendInterval: {\r\n type: \"number\",\r\n title: \"ACK Resend Interval (ms)\",\r\n description: \"Re-send duplicate ACK periodically to recover from lost ACK packets.\",\r\n default: 1000,\r\n minimum: 100,\r\n maximum: 10000\r\n },\r\n nakTimeout: {\r\n type: \"number\",\r\n title: \"NAK Timeout (ms)\",\r\n description: \"Delay before requesting retransmission for missing sequence numbers.\",\r\n default: 100,\r\n minimum: 20,\r\n maximum: 5000\r\n }\r\n }\r\n};\r\n\r\n// ── v2/v3 congestion control (client) ─────────────────────────────────────────\r\n\r\nexport const congestionControlProperty: SchemaFragment = {\r\n type: \"object\",\r\n title: \"Dynamic Congestion Control (v2/v3 only)\",\r\n description:\r\n \"Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions.\",\r\n properties: {\r\n enabled: {\r\n type: \"boolean\",\r\n title: \"Enable Congestion Control\",\r\n description: \"Automatically adjust delta timer based on RTT and packet loss.\",\r\n default: false\r\n },\r\n targetRTT: {\r\n type: \"number\",\r\n title: \"Target RTT (ms)\",\r\n description: \"RTT threshold above which send rate is reduced.\",\r\n default: 200,\r\n minimum: 50,\r\n maximum: 2000\r\n },\r\n nominalDeltaTimer: {\r\n type: \"number\",\r\n title: \"Nominal Delta Timer (ms)\",\r\n description: \"Preferred steady-state send interval.\",\r\n default: 1000,\r\n minimum: 100,\r\n maximum: 10000\r\n },\r\n minDeltaTimer: {\r\n type: \"number\",\r\n title: \"Minimum Delta Timer (ms)\",\r\n description: \"Fastest allowed send interval.\",\r\n default: 100,\r\n minimum: 50,\r\n maximum: 1000\r\n },\r\n maxDeltaTimer: {\r\n type: \"number\",\r\n title: \"Maximum Delta Timer (ms)\",\r\n description: \"Slowest allowed send interval.\",\r\n default: 5000,\r\n minimum: 1000,\r\n maximum: 30000\r\n }\r\n }\r\n};\r\n\r\n// ── v2/v3 connection bonding (client) ─────────────────────────────────────────\r\n\r\nexport const bondingProperty: SchemaFragment = {\r\n type: \"object\",\r\n title: \"Connection Bonding (v2/v3 only)\",\r\n description:\r\n \"Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections.\",\r\n properties: {\r\n enabled: {\r\n type: \"boolean\",\r\n title: \"Enable Connection Bonding\",\r\n description: \"Enable dual-link bonding with automatic failover.\",\r\n default: false\r\n },\r\n mode: {\r\n type: \"string\",\r\n title: \"Bonding Mode\",\r\n description: \"Bonding operating mode.\",\r\n default: \"main-backup\",\r\n oneOf: [\r\n {\r\n const: \"main-backup\",\r\n title: \"Main/Backup – Failover to backup when primary degrades\"\r\n }\r\n ]\r\n },\r\n primary: {\r\n type: \"object\",\r\n title: \"Primary Link\",\r\n description: \"Primary connection (e.g. LTE modem).\",\r\n properties: {\r\n address: { type: \"string\", title: \"Server Address\", default: \"127.0.0.1\" },\r\n port: {\r\n type: \"number\",\r\n title: \"UDP Port\",\r\n default: 4446,\r\n minimum: 1024,\r\n maximum: 65535\r\n },\r\n interface: {\r\n type: \"string\",\r\n title: \"Bind Interface (optional)\",\r\n description: \"Network interface IP to bind to.\"\r\n }\r\n }\r\n },\r\n backup: {\r\n type: \"object\",\r\n title: \"Backup Link\",\r\n description: \"Backup connection (e.g. Starlink, satellite).\",\r\n properties: {\r\n address: { type: \"string\", title: \"Server Address\", default: \"127.0.0.1\" },\r\n port: {\r\n type: \"number\",\r\n title: \"UDP Port\",\r\n default: 4447,\r\n minimum: 1024,\r\n maximum: 65535\r\n },\r\n interface: {\r\n type: \"string\",\r\n title: \"Bind Interface (optional)\",\r\n description: \"Network interface IP to bind to.\"\r\n }\r\n }\r\n },\r\n failover: {\r\n type: \"object\",\r\n title: \"Failover Thresholds\",\r\n description: \"Configure when failover is triggered.\",\r\n properties: {\r\n rttThreshold: {\r\n type: \"number\",\r\n title: \"RTT Threshold (ms)\",\r\n default: 500,\r\n minimum: 100,\r\n maximum: 5000\r\n },\r\n lossThreshold: {\r\n type: \"number\",\r\n title: \"Packet Loss Threshold (0-1)\",\r\n default: 0.1,\r\n minimum: 0.01,\r\n maximum: 0.5\r\n },\r\n healthCheckInterval: {\r\n type: \"number\",\r\n title: \"Health Check Interval (ms)\",\r\n default: 1000,\r\n minimum: 500,\r\n maximum: 10000\r\n },\r\n failbackDelay: {\r\n type: \"number\",\r\n title: \"Failback Delay (ms)\",\r\n default: 30000,\r\n minimum: 5000,\r\n maximum: 300000\r\n },\r\n heartbeatTimeout: {\r\n type: \"number\",\r\n title: \"Heartbeat Timeout (ms)\",\r\n default: 5000,\r\n minimum: 1000,\r\n maximum: 30000\r\n }\r\n }\r\n }\r\n }\r\n};\r\n\r\n// ── Client-only notifications toggle ──────────────────────────────────────────\r\n\r\nexport const enableNotificationsProperty: SchemaFragment = {\r\n type: \"boolean\",\r\n title: \"Enable Signal K Notifications\",\r\n description: \"Emit Signal K notifications for alerts and failover events.\",\r\n default: false\r\n};\r\n\r\n// ── Client-only: skip forwarding plugin-generated data ────────────────────────\r\n\r\nexport const skipOwnDataProperty: SchemaFragment = {\r\n type: \"boolean\",\r\n title: \"Skip Plugin's Own Data\",\r\n description:\r\n \"Do not forward data this plugin publishes locally over the link. Strips entries under 'networking.edgeLink.*' and the v1 RTT path 'networking.modem.rtt' / 'networking.modem.<id>.rtt'; other 'networking.modem.*' paths from external providers are left intact. Also suppresses the v2/v3 client telemetry packet that mirrors local link metrics to the receiver.\",\r\n default: false\r\n};\r\n\r\n// ── v2/v3 monitoring alert thresholds (client) ────────────────────────────────\r\n\r\nexport const alertThresholdsProperty: SchemaFragment = {\r\n type: \"object\",\r\n title: \"Monitoring Alert Thresholds (v2/v3 only)\",\r\n description: \"Customize warning/critical thresholds for network monitoring alerts.\",\r\n properties: {\r\n rtt: {\r\n type: \"object\",\r\n title: \"RTT Thresholds\",\r\n properties: {\r\n warning: { type: \"number\", title: \"Warning RTT (ms)\", default: 300 },\r\n critical: { type: \"number\", title: \"Critical RTT (ms)\", default: 800 }\r\n }\r\n },\r\n packetLoss: {\r\n type: \"object\",\r\n title: \"Packet Loss Thresholds\",\r\n properties: {\r\n warning: { type: \"number\", title: \"Warning Loss Ratio\", default: 0.03 },\r\n critical: { type: \"number\", title: \"Critical Loss Ratio\", default: 0.1 }\r\n }\r\n },\r\n retransmitRate: {\r\n type: \"object\",\r\n title: \"Retransmit Rate Thresholds\",\r\n properties: {\r\n warning: { type: \"number\", title: \"Warning Retransmit Ratio\", default: 0.05 },\r\n critical: { type: \"number\", title: \"Critical Retransmit Ratio\", default: 0.15 }\r\n }\r\n },\r\n jitter: {\r\n type: \"object\",\r\n title: \"Jitter Thresholds\",\r\n properties: {\r\n warning: { type: \"number\", title: \"Warning Jitter (ms)\", default: 100 },\r\n critical: { type: \"number\", title: \"Critical Jitter (ms)\", default: 300 }\r\n }\r\n },\r\n queueDepth: {\r\n type: \"object\",\r\n title: \"Queue Depth Thresholds\",\r\n properties: {\r\n warning: { type: \"number\", title: \"Warning Queue Depth\", default: 100 },\r\n critical: { type: \"number\", title: \"Critical Queue Depth\", default: 500 }\r\n }\r\n }\r\n }\r\n};\r\n\r\n// ── Builder consumed by the backend (`plugin.schema` in src/index.ts) ─────────\r\n\r\n/**\r\n * Build the `connections[]` item schema used by Signal K's default admin UI\r\n * and served via `GET /plugin-schema`. Client-only fields live under\r\n * `dependencies.serverType.oneOf` so they appear only in client mode.\r\n */\r\nexport function buildConnectionItemSchema(): SchemaFragment {\r\n return {\r\n type: \"object\",\r\n title: \"Connection\",\r\n required: [\"serverType\", \"udpPort\", \"secretKey\"],\r\n properties: { ...commonConnectionProperties },\r\n dependencies: {\r\n serverType: {\r\n oneOf: [\r\n {\r\n properties: {\r\n serverType: { enum: [\"server\"] },\r\n reliability: serverReliabilityProperty\r\n }\r\n },\r\n {\r\n properties: {\r\n serverType: { enum: [\"client\"] },\r\n ...clientTransportProperties,\r\n reliability: clientReliabilityProperty,\r\n congestionControl: congestionControlProperty,\r\n bonding: bondingProperty,\r\n enableNotifications: enableNotificationsProperty,\r\n skipOwnData: skipOwnDataProperty,\r\n alertThresholds: alertThresholdsProperty\r\n },\r\n required: [\"udpAddress\", \"testAddress\", \"testPort\"]\r\n }\r\n ]\r\n }\r\n }\r\n };\r\n}\r\n\r\n// ── Builder consumed by the webapp (PluginConfigurationPanel.tsx) ─────────────\r\n\r\n/**\r\n * Build the flat per-connection schema consumed by the webapp RJSF form.\r\n * Unlike the backend variant this is a flat object that is rebuilt whenever\r\n * the user toggles `serverType` or `protocolVersion` so RJSF re-renders with\r\n * the right subset of fields.\r\n */\r\nexport function buildWebappConnectionSchema(\r\n isClient: boolean,\r\n protocolVersion: number | undefined\r\n): SchemaFragment {\r\n const isReliableProtocol = Number(protocolVersion) >= 2;\r\n const props: Record<string, SchemaFragment> = { ...commonConnectionProperties };\r\n const required = [\"serverType\", \"udpPort\", \"secretKey\"];\r\n\r\n if (isClient) {\r\n Object.assign(props, clientTransportProperties);\r\n props.enableNotifications = enableNotificationsProperty;\r\n props.skipOwnData = skipOwnDataProperty;\r\n required.push(\"udpAddress\", \"testAddress\", \"testPort\");\r\n if (isReliableProtocol) {\r\n props.reliability = clientReliabilityProperty;\r\n props.congestionControl = congestionControlProperty;\r\n props.bonding = bondingProperty;\r\n props.alertThresholds = alertThresholdsProperty;\r\n }\r\n } else if (isReliableProtocol) {\r\n props.reliability = serverReliabilityProperty;\r\n }\r\n\r\n return { type: \"object\", required, properties: props };\r\n}\r\n","import React from 'react';\r\nimport { useState, useEffect, useCallback, useRef } from 'react';\r\nimport Form from \"@rjsf/core\";\r\nimport validator from \"@rjsf/validator-ajv8\";\r\nimport { RJSFSchema, UiSchema, getDefaultFormState } from \"@rjsf/utils\";\r\nimport { apiFetch, MANAGEMENT_TOKEN_ERROR_MESSAGE } from \"../utils/apiFetch\";\r\nimport { buildWebappConnectionSchema } from \"../../shared/connection-schema\";\r\n\r\nconst API_BASE = \"/plugins/signalk-edge-link\";\r\n\r\n// ── Stable ID helper ──────────────────────────────────────────────────────────\r\n// Each connection object carries a frontend-only `_id` for use as React key.\r\n// It is stripped before the array is POSTed to the backend.\r\n\r\nlet _idSeq = 0;\r\nfunction makeId(): string { return `skel-${Date.now()}-${++_idSeq}`; }\r\n\r\n// ── Types ─────────────────────────────────────────────────────────────────────\r\n\r\ninterface ConnectionData {\r\n _id: string;\r\n name?: string;\r\n serverType?: string;\r\n udpPort?: number;\r\n secretKey?: string;\r\n stretchAsciiKey?: boolean;\r\n useMsgpack?: boolean;\r\n usePathDictionary?: boolean;\r\n enableNotifications?: boolean;\r\n skipOwnData?: boolean;\r\n protocolVersion?: number;\r\n udpAddress?: string;\r\n helloMessageSender?: number;\r\n testAddress?: string;\r\n testPort?: number;\r\n pingIntervalTime?: number;\r\n [key: string]: unknown;\r\n}\r\n\r\ninterface SaveStatus {\r\n type: \"saving\" | \"success\" | \"error\";\r\n message: string;\r\n}\r\n\r\n// ── Default config factories ──────────────────────────────────────────────────\r\n\r\nfunction defaultClientConnection(name?: string): ConnectionData {\r\n return {\r\n _id: makeId(),\r\n name: name || \"client\",\r\n serverType: \"client\",\r\n udpPort: 4446,\r\n secretKey: \"\",\r\n stretchAsciiKey: false,\r\n useMsgpack: false,\r\n usePathDictionary: false,\r\n enableNotifications: false,\r\n skipOwnData: false,\r\n protocolVersion: 1,\r\n udpAddress: \"127.0.0.1\",\r\n helloMessageSender: 60,\r\n testAddress: \"127.0.0.1\",\r\n testPort: 80,\r\n pingIntervalTime: 1\r\n };\r\n}\r\n\r\nfunction defaultServerConnection(name?: string): ConnectionData {\r\n return {\r\n _id: makeId(),\r\n name: name || \"server\",\r\n serverType: \"server\",\r\n udpPort: 4446,\r\n secretKey: \"\",\r\n stretchAsciiKey: false,\r\n useMsgpack: false,\r\n usePathDictionary: false,\r\n protocolVersion: 1\r\n };\r\n}\r\n\r\n/** Attach a stable _id to loaded connections that don't already have one. */\r\nfunction withId(conn: Omit<ConnectionData, \"_id\"> & { _id?: string }): ConnectionData {\r\n return conn._id ? (conn as ConnectionData) : { ...conn, _id: makeId() };\r\n}\r\n\r\n// Fill schema defaults into loaded form data so RJSF has nothing to augment on\r\n// mount — otherwise RJSF fires a synthetic onChange for every field that is\r\n// defined in the schema but absent from the persisted config (e.g.\r\n// stretchAsciiKey on pre-existing connections), which would trip the dirty flag\r\n// and surface \"Unsaved changes\" immediately after a fresh load.\r\nfunction withSchemaDefaults(conn: ConnectionData): ConnectionData {\r\n const isClient = conn.serverType !== \"server\";\r\n const schema = buildWebappConnectionSchema(isClient, conn.protocolVersion) as RJSFSchema;\r\n const { _id, ...formData } = conn;\r\n const enriched = getDefaultFormState(validator, schema, formData) as Record<string, unknown>;\r\n return { ...(enriched as Omit<ConnectionData, \"_id\">), _id };\r\n}\r\n\r\n// Deep equality that is insensitive to key insertion order (unlike\r\n// JSON.stringify). Used to decide whether an RJSF onChange carries a real\r\n// field-level difference.\r\nfunction stableStringify(value: unknown): string {\r\n if (value === null || typeof value !== \"object\") { return JSON.stringify(value); }\r\n if (Array.isArray(value)) {\r\n return \"[\" + value.map(stableStringify).join(\",\") + \"]\";\r\n }\r\n const obj = value as Record<string, unknown>;\r\n const keys = Object.keys(obj).sort();\r\n return \"{\" + keys.map((k) => JSON.stringify(k) + \":\" + stableStringify(obj[k])).join(\",\") + \"}\";\r\n}\r\n\r\nfunction connectionsEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {\r\n const aKeys = Object.keys(a);\r\n const bKeys = Object.keys(b);\r\n if (aKeys.length !== bKeys.length) { return false; }\r\n for (const k of aKeys) {\r\n if (!Object.prototype.hasOwnProperty.call(b, k)) { return false; }\r\n const av = a[k];\r\n const bv = b[k];\r\n if (av === bv) { continue; }\r\n if (av !== null && bv !== null && typeof av === \"object\" && typeof bv === \"object\") {\r\n if (stableStringify(av) !== stableStringify(bv)) { return false; }\r\n continue;\r\n }\r\n return false;\r\n }\r\n return true;\r\n}\r\n\r\n// ── Schema ────────────────────────────────────────────────────────────────────\r\n// Single source of truth for field definitions: src/shared/connection-schema.ts\r\n// (also consumed by plugin.schema in src/index.ts).\r\n\r\nconst uiSchemaClient: UiSchema = {\r\n \"ui:order\": [\r\n \"name\", \"serverType\", \"udpAddress\", \"udpPort\", \"secretKey\", \"stretchAsciiKey\", \"protocolVersion\",\r\n \"useMsgpack\", \"usePathDictionary\", \"testAddress\", \"testPort\", \"pingIntervalTime\",\r\n \"helloMessageSender\", \"heartbeatInterval\", \"reliability\", \"congestionControl\", \"bonding\", \"skipOwnData\", \"enableNotifications\", \"alertThresholds\"\r\n ],\r\n secretKey: { \"ui:widget\": \"password\", \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\" },\r\n stretchAsciiKey: { \"ui:help\": \"Only applies to 32-char ASCII keys. Must match on both peers.\" },\r\n serverType: { \"ui:widget\": \"select\" },\r\n reliability: {\r\n \"ui:classNames\": \"skel-optional-group\"\r\n },\r\n congestionControl: {\r\n \"ui:classNames\": \"skel-optional-group\"\r\n },\r\n bonding: {\r\n \"ui:classNames\": \"skel-optional-group\"\r\n },\r\n alertThresholds: {\r\n \"ui:classNames\": \"skel-optional-group\"\r\n }\r\n};\r\n\r\nconst uiSchemaServer: UiSchema = {\r\n \"ui:order\": [\r\n \"name\", \"serverType\", \"udpPort\", \"secretKey\", \"stretchAsciiKey\", \"useMsgpack\", \"usePathDictionary\",\r\n \"protocolVersion\", \"reliability\"\r\n ],\r\n secretKey: { \"ui:widget\": \"password\", \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\" },\r\n stretchAsciiKey: { \"ui:help\": \"Only applies to 32-char ASCII keys. Must match on both peers.\" },\r\n serverType: { \"ui:widget\": \"select\" }\r\n};\r\n\r\n// Shared fields preserved when the user toggles server <-> client mode\r\nconst SHARED_FIELDS = [\"name\", \"udpPort\", \"secretKey\", \"stretchAsciiKey\", \"useMsgpack\", \"usePathDictionary\", \"protocolVersion\"];\r\n\r\n// ── Styles ────────────────────────────────────────────────────────────────────\r\n// Using `skel-` prefix (Signal K Edge Link) to avoid collisions with other\r\n// plugins that may inject CSS into the same admin panel page.\r\n\r\nconst css = `\r\n.skel-config { font-family: inherit; }\r\n.skel-dirty-banner {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n padding: 8px 14px;\r\n background: #fff3cd;\r\n color: #664d03;\r\n border: 1px solid #ffe69c;\r\n border-radius: 4px;\r\n margin-bottom: 12px;\r\n font-size: 0.88rem;\r\n}\r\n.skel-card {\r\n border: 1px solid #dee2e6;\r\n border-radius: 6px;\r\n margin-bottom: 12px;\r\n overflow: hidden;\r\n}\r\n.skel-card-header {\r\n display: flex;\r\n align-items: center;\r\n padding: 10px 14px;\r\n background: #f8f9fa;\r\n cursor: pointer;\r\n user-select: none;\r\n gap: 10px;\r\n}\r\n.skel-card-header:hover { background: #e9ecef; }\r\n.skel-badge {\r\n display: inline-block;\r\n padding: 2px 8px;\r\n border-radius: 12px;\r\n font-size: 0.75rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n}\r\n.skel-badge-server { background: #cfe2ff; color: #084298; }\r\n.skel-badge-client { background: #d1e7dd; color: #0a3622; }\r\n.skel-card-title { font-weight: 600; flex: 1; }\r\n.skel-expand-icon { font-size: 0.8rem; color: #6c757d; }\r\n.skel-btn-remove {\r\n background: none;\r\n border: 1px solid #dc3545;\r\n color: #dc3545;\r\n border-radius: 4px;\r\n padding: 2px 8px;\r\n font-size: 0.8rem;\r\n cursor: pointer;\r\n}\r\n.skel-btn-remove:hover { background: #dc3545; color: white; }\r\n.skel-btn-remove:disabled { opacity: 0.4; cursor: default; border-color: #aaa; color: #aaa; }\r\n.skel-btn-remove:disabled:hover { background: none; }\r\n.skel-card-body { padding: 16px; border-top: 1px solid #dee2e6; }\r\n.skel-toolbar {\r\n display: flex;\r\n gap: 10px;\r\n align-items: center;\r\n margin-top: 16px;\r\n padding-top: 16px;\r\n border-top: 1px solid #dee2e6;\r\n flex-wrap: wrap;\r\n}\r\n.skel-btn {\r\n padding: 7px 16px;\r\n border-radius: 4px;\r\n font-size: 0.95rem;\r\n cursor: pointer;\r\n border: none;\r\n}\r\n.skel-btn-primary { background: #0d6efd; color: white; }\r\n.skel-btn-primary:hover { background: #0b5ed7; }\r\n.skel-btn-primary:disabled { background: #6c757d; cursor: default; }\r\n.skel-btn-secondary { background: white; color: #0d6efd; border: 1px solid #0d6efd; }\r\n.skel-btn-secondary:hover { background: #e7f0ff; }\r\n.skel-alert {\r\n padding: 10px 14px;\r\n border-radius: 4px;\r\n margin-bottom: 14px;\r\n font-size: 0.9rem;\r\n}\r\n.skel-alert-success { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }\r\n.skel-alert-error { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }\r\n.skel-alert-saving { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }\r\n.skel-dup-warn { font-size: 0.8rem; color: #dc3545; margin-top: 4px; }\r\n.skel-plugin-settings {\r\n border: 1px solid #dee2e6;\r\n border-radius: 6px;\r\n margin-bottom: 20px;\r\n padding: 16px;\r\n background: #f8f9fa;\r\n}\r\n.skel-plugin-settings h3 {\r\n margin: 0 0 12px;\r\n font-size: 1rem;\r\n font-weight: 600;\r\n}\r\n.skel-field-group {\r\n margin-bottom: 14px;\r\n}\r\n.skel-field-group label {\r\n display: block;\r\n font-weight: 500;\r\n margin-bottom: 4px;\r\n font-size: 0.9rem;\r\n}\r\n.skel-field-group input[type=\"text\"],\r\n.skel-field-group input[type=\"password\"] {\r\n width: 100%;\r\n max-width: 420px;\r\n padding: 6px 10px;\r\n border: 1px solid #ced4da;\r\n border-radius: 4px;\r\n font-size: 0.9rem;\r\n}\r\n.skel-field-group input[type=\"checkbox\"] {\r\n margin-right: 6px;\r\n}\r\n.skel-field-desc {\r\n font-size: 0.8rem;\r\n color: #5c6773;\r\n margin-top: 3px;\r\n}\r\n.skel-config .field-description {\r\n color: #5c6773;\r\n font-size: 0.83rem;\r\n line-height: 1.35;\r\n}\r\n.skel-config legend,\r\n.skel-config label {\r\n line-height: 1.2;\r\n overflow-wrap: anywhere;\r\n}\r\n.skel-optional-group {\r\n margin-top: 12px;\r\n border: 1px dashed #ccd5df;\r\n border-radius: 6px;\r\n padding: 10px 12px 4px;\r\n background: #fbfcfe;\r\n}\r\n.skel-optional-group legend {\r\n font-size: 0.92rem;\r\n margin-bottom: 6px;\r\n}\r\n.skel-optional-group .form-group {\r\n margin-bottom: 10px;\r\n}\r\n.skel-optional-group .form-control {\r\n max-width: 340px;\r\n}\r\n`;\r\n\r\n// ── ConnectionCard ────────────────────────────────────────────────────────────\r\n\r\ninterface ConnectionCardProps {\r\n conn: ConnectionData;\r\n index: number;\r\n totalCount: number;\r\n expanded: boolean;\r\n onToggle: () => void;\r\n onChange: (data: ConnectionData) => void;\r\n onRemove: () => void;\r\n}\r\n\r\nfunction ConnectionCard({ conn, index, totalCount, expanded, onToggle, onChange, onRemove }: ConnectionCardProps) {\r\n const isClient = conn.serverType !== \"server\";\r\n const schema = buildWebappConnectionSchema(isClient, conn.protocolVersion) as RJSFSchema;\r\n const uiSchema = isClient ? uiSchemaClient : uiSchemaServer;\r\n const modeLabel = isClient ? \"Client\" : \"Server\";\r\n const displayName = (conn.name || `Connection ${index + 1}`).trim();\r\n\r\n function handleFormChange(e: any) {\r\n const next: ConnectionData = e.formData;\r\n if (next.serverType !== conn.serverType) {\r\n const base = next.serverType === \"server\"\r\n ? defaultServerConnection(next.name)\r\n : defaultClientConnection(next.name);\r\n const merged: ConnectionData = { ...base, _id: conn._id };\r\n for (const k of SHARED_FIELDS) {\r\n if (next[k] !== undefined) { (merged as Record<string, unknown>)[k] = next[k]; }\r\n }\r\n merged.serverType = next.serverType;\r\n onChange(merged);\r\n return;\r\n }\r\n // Skip propagation when the incoming form data is identical to the current\r\n // connection — RJSF can fire onChange with no effective diff (e.g. after\r\n // internal re-renders), and we do not want that to trip the dirty flag.\r\n // Order-insensitive compare so a reshuffled-but-equivalent formData does\r\n // not look like a real edit.\r\n const proposed: ConnectionData = { ...next, _id: conn._id };\r\n const { _id: _aId, ...a } = proposed;\r\n const { _id: _bId, ...b } = conn;\r\n if (connectionsEqual(a, b)) { return; }\r\n onChange(proposed);\r\n }\r\n\r\n // Strip the frontend-only _id before passing to RJSF\r\n const { _id, ...formData } = conn;\r\n\r\n return (\r\n <div className=\"skel-card\">\r\n <div className=\"skel-card-header\" onClick={onToggle} role=\"button\" aria-expanded={expanded}>\r\n <span className={`skel-badge ${isClient ? \"skel-badge-client\" : \"skel-badge-server\"}`}>\r\n {modeLabel}\r\n </span>\r\n <span className=\"skel-card-title\">{displayName}</span>\r\n <span className=\"skel-expand-icon\">{expanded ? \"\\u25B2\" : \"\\u25BC\"}</span>\r\n <button\r\n className=\"skel-btn-remove\"\r\n disabled={totalCount <= 1}\r\n onClick={(e) => { e.stopPropagation(); onRemove(); }}\r\n title={totalCount <= 1 ? \"Cannot remove the only connection\" : \"Remove this connection\"}\r\n >\r\n Remove\r\n </button>\r\n </div>\r\n {expanded && (\r\n <div className=\"skel-card-body\">\r\n <Form\r\n schema={schema}\r\n uiSchema={uiSchema}\r\n formData={formData}\r\n validator={validator}\r\n onChange={handleFormChange}\r\n onSubmit={() => {}}\r\n liveValidate={false}\r\n >\r\n {/* Hide the default submit button – saving is done from the outer toolbar */}\r\n <div />\r\n </Form>\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// ── Main panel ────────────────────────────────────────────────────────────────\r\n\r\nfunction PluginConfigurationPanel(_props: Record<string, unknown>) {\r\n const [connections, setConnections] = useState<ConnectionData[]>([]);\r\n const [managementApiToken, setManagementApiToken] = useState<string>(\"\");\r\n const [requireManagementApiToken, setRequireManagementApiToken] = useState<boolean>(false);\r\n const [loading, setLoading] = useState(true);\r\n const [loadError, setLoadError] = useState<string | null>(null);\r\n const [saveStatus, setSaveStatus] = useState<SaveStatus | null>(null);\r\n const [inlineValidationMessage, setInlineValidationMessage] = useState<string | null>(null);\r\n const [expandedIndex, setExpandedIndex] = useState<number | null>(0);\r\n const [isDirty, setIsDirty] = useState(false);\r\n const savingRef = useRef(false);\r\n\r\n // ── Load config ─────────────────────────────────────────────────────────────\r\n useEffect(() => {\r\n async function load() {\r\n try {\r\n const res = await apiFetch(`${API_BASE}/plugin-config`);\r\n if (res.status === 401) {\r\n throw new Error(MANAGEMENT_TOKEN_ERROR_MESSAGE);\r\n }\r\n if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); }\r\n const body = await res.json();\r\n if (!body.success) { throw new Error(body.error || \"Failed to load configuration\"); }\r\n\r\n const cfg = body.configuration || {};\r\n let list: ConnectionData[];\r\n if (Array.isArray(cfg.connections) && cfg.connections.length > 0) {\r\n list = cfg.connections.map((c: Omit<ConnectionData, \"_id\">) => withSchemaDefaults(withId(c)));\r\n } else if (cfg.serverType) {\r\n list = [withSchemaDefaults(withId(cfg))];\r\n } else {\r\n list = [defaultClientConnection()];\r\n }\r\n setConnections(list);\r\n setManagementApiToken(typeof cfg.managementApiToken === \"string\" ? cfg.managementApiToken : \"\");\r\n setRequireManagementApiToken(cfg.requireManagementApiToken === true);\r\n setExpandedIndex(0);\r\n setIsDirty(false);\r\n } catch (err: unknown) {\r\n setLoadError(err instanceof Error ? err.message : String(err));\r\n } finally {\r\n setLoading(false);\r\n }\r\n }\r\n load();\r\n }, []);\r\n\r\n // ── Duplicate server-port detection ─────────────────────────────────────────\r\n const serverPorts = connections\r\n .filter((c) => c.serverType === \"server\")\r\n .map((c) => c.udpPort);\r\n const duplicatePortSet = new Set(\r\n serverPorts.filter((p, i) => serverPorts.indexOf(p) !== i)\r\n );\r\n\r\n // ── Handlers ─────────────────────────────────────────────────────────────────\r\n function markDirty() {\r\n setIsDirty(true);\r\n setSaveStatus(null);\r\n setInlineValidationMessage(null);\r\n }\r\n\r\n function updateConnection(idx: number, data: ConnectionData) {\r\n setConnections((prev) => prev.map((c, i) => (i === idx ? data : c)));\r\n markDirty();\r\n }\r\n\r\n function addServer() {\r\n setConnections((prev) => {\r\n const next = [...prev, defaultServerConnection(`server-${prev.length + 1}`)];\r\n setExpandedIndex(next.length - 1);\r\n return next;\r\n });\r\n markDirty();\r\n }\r\n\r\n function addClient() {\r\n setConnections((prev) => {\r\n const next = [...prev, defaultClientConnection(`client-${prev.length + 1}`)];\r\n setExpandedIndex(next.length - 1);\r\n return next;\r\n });\r\n markDirty();\r\n }\r\n\r\n function removeConnection(idx: number) {\r\n setConnections((prev) => {\r\n if (prev.length <= 1) return prev;\r\n const next = prev.filter((_, i) => i !== idx);\r\n setExpandedIndex((prevExpanded) => (prevExpanded !== null && prevExpanded >= idx && prevExpanded > 0 ? prevExpanded - 1 : prevExpanded));\r\n return next;\r\n });\r\n markDirty();\r\n }\r\n\r\n function toggleExpand(idx: number) {\r\n setExpandedIndex((prev) => (prev === idx ? null : idx));\r\n }\r\n\r\n const handleSave = useCallback(async () => {\r\n if (savingRef.current) { return; }\r\n if (connections.length === 0) {\r\n setInlineValidationMessage(\"At least one connection is required before saving.\");\r\n setSaveStatus({\r\n type: \"error\",\r\n message: \"Cannot save an empty configuration. Add at least one connection.\"\r\n });\r\n return;\r\n }\r\n\r\n setInlineValidationMessage(null);\r\n if (duplicatePortSet.size > 0) {\r\n setSaveStatus({\r\n type: \"error\",\r\n message: `Duplicate server ports detected: ${[...duplicatePortSet].join(\", \")}. Each server must use a unique UDP port.`\r\n });\r\n return;\r\n }\r\n\r\n savingRef.current = true;\r\n setSaveStatus({ type: \"saving\", message: \"Saving configuration...\" });\r\n try {\r\n const payload = connections.map(({ _id, ...rest }) => rest);\r\n const res = await apiFetch(`${API_BASE}/plugin-config`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify({\r\n connections: payload,\r\n managementApiToken: managementApiToken,\r\n requireManagementApiToken: requireManagementApiToken\r\n })\r\n });\r\n if (res.status === 401) {\r\n throw new Error(MANAGEMENT_TOKEN_ERROR_MESSAGE);\r\n }\r\n const body = await res.json();\r\n if (res.ok && body.success) {\r\n setSaveStatus({ type: \"success\", message: body.message || \"Configuration saved. Plugin restarting...\" });\r\n setIsDirty(false);\r\n } else {\r\n throw new Error(body.error || \"Failed to save\");\r\n }\r\n } catch (err: unknown) {\r\n setSaveStatus({ type: \"error\", message: err instanceof Error ? err.message : String(err) });\r\n } finally {\r\n savingRef.current = false;\r\n }\r\n }, [connections, duplicatePortSet, managementApiToken, requireManagementApiToken]);\r\n\r\n // ── Render ────────────────────────────────────────────────────────────────────\r\n if (loading) {\r\n return <div style={{ padding: \"20px\", textAlign: \"center\" }}>Loading configuration...</div>;\r\n }\r\n\r\n if (loadError) {\r\n return (\r\n <div style={{ padding: \"20px\" }}>\r\n <div className=\"skel-alert skel-alert-error\">\r\n <strong>Error loading configuration:</strong> {loadError}\r\n </div>\r\n </div>\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"skel-config\">\r\n <style>{css}</style>\r\n\r\n {isDirty && saveStatus?.type !== \"saving\" && (\r\n <div className=\"skel-dirty-banner\">\r\n <span>&#9888;</span>\r\n <span>You have unsaved changes.</span>\r\n </div>\r\n )}\r\n\r\n {saveStatus && (\r\n <div className={`skel-alert skel-alert-${saveStatus.type === \"saving\" ? \"saving\" : saveStatus.type === \"success\" ? \"success\" : \"error\"}`}>\r\n {saveStatus.message}\r\n </div>\r\n )}\r\n\r\n {/* Plugin-level security settings */}\r\n <div className=\"skel-plugin-settings\">\r\n <h3>Plugin Security Settings</h3>\r\n <div className=\"skel-field-group\">\r\n <label htmlFor=\"skel-mgmt-token\">Management API Token</label>\r\n <input\r\n id=\"skel-mgmt-token\"\r\n type=\"password\"\r\n value={managementApiToken}\r\n placeholder=\"Leave empty for open access\"\r\n onChange={(e) => { setManagementApiToken(e.target.value); markDirty(); }}\r\n autoComplete=\"new-password\"\r\n />\r\n <div className=\"skel-field-desc\">\r\n Shared secret to protect the management API endpoints. Strongly recommended for\r\n production. Can also be set via the{\" \"}\r\n <code>SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN</code> environment variable (env var takes\r\n priority). Leave empty to allow open access.\r\n </div>\r\n </div>\r\n <div className=\"skel-field-group\">\r\n <label>\r\n <input\r\n type=\"checkbox\"\r\n checked={requireManagementApiToken}\r\n onChange={(e) => { setRequireManagementApiToken(e.target.checked); markDirty(); }}\r\n />\r\n Require Management API Token\r\n </label>\r\n <div className=\"skel-field-desc\">\r\n When enabled, all management API requests are rejected if no token is configured\r\n (fail-closed). When disabled, requests are allowed if no token is set (open access).\r\n </div>\r\n </div>\r\n </div>\r\n\r\n {connections.map((conn, idx) => (\r\n <div key={conn._id}>\r\n <ConnectionCard\r\n conn={conn}\r\n index={idx}\r\n totalCount={connections.length}\r\n expanded={expandedIndex === idx}\r\n onToggle={() => toggleExpand(idx)}\r\n onChange={(data: ConnectionData) => updateConnection(idx, data)}\r\n onRemove={() => removeConnection(idx)}\r\n />\r\n {conn.serverType === \"server\" && duplicatePortSet.has(conn.udpPort) && (\r\n <div className=\"skel-dup-warn\">\r\n Port {conn.udpPort} is used by multiple server connections. Each server requires a unique port.\r\n </div>\r\n )}\r\n </div>\r\n ))}\r\n\r\n <div className=\"skel-toolbar\">\r\n <button className=\"skel-btn skel-btn-secondary\" onClick={addServer}>\r\n + Add Server\r\n </button>\r\n <button className=\"skel-btn skel-btn-secondary\" onClick={addClient}>\r\n + Add Client\r\n </button>\r\n <button\r\n className=\"skel-btn skel-btn-primary\"\r\n onClick={handleSave}\r\n disabled={(saveStatus && saveStatus.type === \"saving\") || connections.length === 0}\r\n >\r\n {isDirty ? \"Save Changes\" : \"Save Configuration\"}\r\n </button>\r\n {inlineValidationMessage && (\r\n <span style={{ color: \"#dc3545\", fontSize: \"0.85rem\", fontWeight: 500 }}>\r\n {inlineValidationMessage}\r\n </span>\r\n )}\r\n <span style={{ fontSize: \"0.85rem\", color: \"#6c757d\" }}>\r\n {connections.length} connection{connections.length !== 1 ? \"s\" : \"\"}\r\n {\" \\u00B7 \"}\r\n {connections.filter((c) => c.serverType === \"server\").length} server\r\n {connections.filter((c) => c.serverType === \"server\").length !== 1 ? \"s\" : \"\"}\r\n {\", \"}\r\n {connections.filter((c) => c.serverType !== \"server\").length} client\r\n {connections.filter((c) => c.serverType !== \"server\").length !== 1 ? \"s\" : \"\"}\r\n </span>\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\nexport default PluginConfigurationPanel;\r\n"],"names":["MANAGEMENT_TOKEN_ERROR_MESSAGE","DEFAULT_AUTH_CONFIG","token","localStorageKey","queryParam","includeTokenInQuery","headerMode","apiFetch","input","init","config","window","runtime","__EDGE_LINK_AUTH__","readRuntimeAuthConfig","String","trim","tokenFromQuery","URLSearchParams","location","search","get","localStorage","tokenFromStorage","getItem","resolveToken","headers","Headers","normalizedMode","toLowerCase","set","attachAuthHeaders","fetch","commonConnectionProperties","name","type","title","description","default","maxLength","serverType","oneOf","const","udpPort","minimum","maximum","secretKey","minLength","pattern","stretchAsciiKey","toLocaleString","useMsgpack","usePathDictionary","protocolVersion","clientTransportProperties","udpAddress","helloMessageSender","testAddress","testPort","pingIntervalTime","heartbeatInterval","clientReliabilityProperty","properties","retransmitQueueSize","maxRetransmits","retransmitMaxAge","retransmitMinAge","retransmitRttMultiplier","ackIdleDrainAge","forceDrainAfterAckIdle","forceDrainAfterMs","recoveryBurstEnabled","recoveryBurstSize","recoveryBurstIntervalMs","recoveryAckGapMs","serverReliabilityProperty","ackInterval","ackResendInterval","nakTimeout","congestionControlProperty","enabled","targetRTT","nominalDeltaTimer","minDeltaTimer","maxDeltaTimer","bondingProperty","mode","primary","address","port","interface","backup","failover","rttThreshold","lossThreshold","healthCheckInterval","failbackDelay","heartbeatTimeout","enableNotificationsProperty","skipOwnDataProperty","alertThresholdsProperty","rtt","warning","critical","packetLoss","retransmitRate","jitter","queueDepth","buildWebappConnectionSchema","isClient","isReliableProtocol","Number","props","required","Object","assign","enableNotifications","skipOwnData","push","reliability","congestionControl","bonding","alertThresholds","API_BASE","_idSeq","makeId","Date","now","defaultClientConnection","_id","defaultServerConnection","withId","conn","withSchemaDefaults","schema","formData","stableStringify","value","JSON","stringify","Array","isArray","map","join","obj","keys","sort","k","uiSchemaClient","uiSchemaServer","SHARED_FIELDS","ConnectionCard","index","totalCount","expanded","onToggle","onChange","onRemove","uiSchema","modeLabel","displayName","className","onClick","role","disabled","e","stopPropagation","validator","next","merged","undefined","proposed","_aId","a","_bId","b","aKeys","bKeys","length","prototype","hasOwnProperty","call","av","bv","connectionsEqual","onSubmit","liveValidate","_props","connections","setConnections","useState","managementApiToken","setManagementApiToken","requireManagementApiToken","setRequireManagementApiToken","loading","setLoading","loadError","setLoadError","saveStatus","setSaveStatus","inlineValidationMessage","setInlineValidationMessage","expandedIndex","setExpandedIndex","isDirty","setIsDirty","savingRef","useRef","useEffect","async","res","status","Error","ok","statusText","body","json","success","error","cfg","configuration","list","c","err","message","load","serverPorts","filter","duplicatePortSet","Set","p","i","indexOf","markDirty","handleSave","useCallback","current","size","payload","rest","method","style","padding","textAlign","htmlFor","id","placeholder","target","autoComplete","checked","idx","key","prev","toggleExpand","data","updateConnection","_","prevExpanded","removeConnection","has","color","fontSize","fontWeight"],"sourceRoot":""}
package/public/index.html CHANGED
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SignalK Edge Link Configuration</title><link rel="icon" type="image/png" href="/plugins/signalk-edge-link/icon.png"><script defer="defer" src="main.0b6f5e3267731da945f0.js"></script><script defer="defer" src="remoteEntry.js"></script><link href="main.e2b9c98749816ac2e285.css" rel="stylesheet"></head><body><div id="app"><header class="header"><h1>SignalK Edge Link</h1><p class="subtitle">Configuration and runtime monitoring</p></header><div id="connectionTabs" class="connection-tabs"></div><div class="container"><div id="connectionContent"></div></div></div><div id="notification" class="notification"></div></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SignalK Edge Link Configuration</title><link rel="icon" type="image/png" href="/plugins/signalk-edge-link/icon.png"><script defer="defer" src="main.0b6f5e3267731da945f0.js"></script><script defer="defer" src="remoteEntry.js"></script><link href="main.2ae3dd54effad689f0da.css" rel="stylesheet"></head><body><div id="app"><header class="header"><h1>SignalK Edge Link</h1><p class="subtitle">Configuration and runtime monitoring</p></header><div id="connectionTabs" class="connection-tabs"></div><div class="container"><div id="connectionContent"></div></div></div><div id="notification" class="notification"></div></body></html>