signalk-edge-link 2.6.1 → 2.6.2

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/index.js CHANGED
@@ -175,25 +175,31 @@ module.exports = function createPlugin(app) {
175
175
  });
176
176
  instances.set(instanceId, instance);
177
177
  }
178
- // Start all instances concurrently.
179
- // Track which ones started successfully so that, on partial failure,
180
- // only the started instances are stopped avoiding double-cleanup of
181
- // instances that never completed start() and the dangling timer / socket
182
- // leaks that would follow.
178
+ // Start servers before clients so that in a co-located proxy setup the
179
+ // local server is already listening by the time the local client fires its
180
+ // initial snapshot replay. Servers and clients within each group start
181
+ // concurrently; the two groups are sequenced (servers first).
183
182
  const startedInstances = [];
184
183
  let startError = null;
185
- await Promise.all([...instances.values()].map(async (inst) => {
186
- try {
187
- await inst.start();
188
- startedInstances.push(inst);
189
- }
190
- catch (err) {
191
- if (!startError) {
192
- startError = err;
184
+ const allInstances = [...instances.values()];
185
+ const serverInstances = allInstances.filter((inst) => inst.isServerMode());
186
+ const clientInstances = allInstances.filter((inst) => !inst.isServerMode());
187
+ async function startGroup(group) {
188
+ await Promise.all(group.map(async (inst) => {
189
+ try {
190
+ await inst.start();
191
+ startedInstances.push(inst);
193
192
  }
194
- app.error(`Failed to start connection: ${err instanceof Error ? err.message : String(err)}`);
195
- }
196
- }));
193
+ catch (err) {
194
+ if (!startError) {
195
+ startError = err;
196
+ }
197
+ app.error(`Failed to start connection: ${err instanceof Error ? err.message : String(err)}`);
198
+ }
199
+ }));
200
+ }
201
+ await startGroup(serverInstances);
202
+ await startGroup(clientInstances);
197
203
  if (startError) {
198
204
  app.error(`Failed to start one or more connections — stopping all started instances`);
199
205
  for (const inst of startedInstances) {
package/lib/instance.js CHANGED
@@ -58,7 +58,7 @@ function slugify(name) {
58
58
  * @param instanceId - URL-safe unique identifier for this connection
59
59
  * @param pluginId - Plugin ID (used as source label in SK messages)
60
60
  * @param onStatusChange - Called as (instanceId, message) whenever status changes
61
- * @returns Instance API: { start, stop, getId, getName, getStatus, getState, getMetricsApi }
61
+ * @returns Instance API: { start, stop, isServerMode, getId, getName, getStatus, getState, getMetricsApi }
62
62
  */
63
63
  function createInstance(app, options, instanceId, pluginId, onStatusChange) {
64
64
  // ── Per-instance state ────────────────────────────────────────────────────
@@ -1123,6 +1123,11 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1123
1123
  const msg = err instanceof Error ? err.message : String(err);
1124
1124
  app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
1125
1125
  });
1126
+ // The initial-subscribe replayValuesSnapshot fires before readyToSend
1127
+ // is true (pipeline not yet created) and silently returns early. Replay
1128
+ // now so data already in the SK tree — including values injected by a
1129
+ // co-located server-mode instance — is forwarded on first connect.
1130
+ replayValuesSnapshot("initial connect");
1126
1131
  state.socketUdp.on("message", (msg, rinfo) => {
1127
1132
  v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
1128
1133
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1281,6 +1286,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1281
1286
  return {
1282
1287
  start,
1283
1288
  stop,
1289
+ isServerMode: () => state.isServerMode,
1284
1290
  getId: () => instanceId,
1285
1291
  getName: () => state.instanceName,
1286
1292
  getStatus: () => ({ text: state.instanceStatus, healthy: state.isHealthy }),
@@ -72,13 +72,63 @@ function walkValues(node, pathParts, onLeaf) {
72
72
  walkValues(node[key], pathParts.concat(key), onLeaf);
73
73
  }
74
74
  }
75
+ /**
76
+ * Build a lookup map from $source reference string → structured source object
77
+ * using the top-level `sources` section of the SK full model tree.
78
+ *
79
+ * signalk-server stores sources as sources[provider][key] = { label, type, ... }
80
+ * and formats $source references as "provider.key". This function inverts that
81
+ * structure so we can attach the correct source object to each synthetic update.
82
+ *
83
+ * Falls back to a minimal { label } entry derived from the $source string if
84
+ * the sources section is absent or the exact reference is not found there.
85
+ */
86
+ function buildSourceLookup(tree) {
87
+ const lookup = new Map();
88
+ const sourcesNode = tree.sources;
89
+ if (!isRecord(sourcesNode)) {
90
+ return lookup;
91
+ }
92
+ for (const [provider, providerNode] of Object.entries(sourcesNode)) {
93
+ if (!isRecord(providerNode)) {
94
+ continue;
95
+ }
96
+ for (const [key, sourceObj] of Object.entries(providerNode)) {
97
+ if (isRecord(sourceObj)) {
98
+ lookup.set(`${provider}.${key}`, sourceObj);
99
+ }
100
+ }
101
+ }
102
+ return lookup;
103
+ }
104
+ /**
105
+ * Resolve the source object for a $source reference string. Tries the exact
106
+ * reference in the lookup, then falls back to a { label } derived from the
107
+ * part of the reference before the first ".".
108
+ */
109
+ function resolveSource(sourceRef, lookup) {
110
+ const found = lookup.get(sourceRef);
111
+ if (found) {
112
+ return found;
113
+ }
114
+ // Fallback: provider label is the part before the first "."
115
+ const dotIdx = sourceRef.indexOf(".");
116
+ const label = dotIdx > 0 ? sourceRef.slice(0, dotIdx) : sourceRef;
117
+ return label ? { label } : undefined;
118
+ }
75
119
  /**
76
120
  * Build synthetic deltas for every value currently in the Signal K tree.
77
121
  *
78
- * Returns one delta per `(context, source)` pair, with all matching leaves
79
- * grouped into a single `updates[].values[]` array. `DeltaUpdate.timestamp`
80
- * is per-update (not per-leaf), so the latest timestamp across the group is
81
- * used receivers treat the delta as "current state" anyway.
122
+ * Returns one delta per `(context, source, timestamp)` triple so that the
123
+ * original per-path measurement time is preserved. Values from the same
124
+ * source that were last updated at different times (e.g. GPS speed vs
125
+ * autopilot settings) end up in separate updates rather than being collapsed
126
+ * under the latest timestamp of the group.
127
+ *
128
+ * Each update carries both `$source` (the reference string) and `source`
129
+ * (the structured source object looked up from the SK sources tree) so that
130
+ * `handleMessageBySource` can call `app.handleMessage(source.label, delta)`
131
+ * with the original instrument label rather than an empty provider ID.
82
132
  *
83
133
  * Returns [] when `app.signalk` isn't exposed (older signalk-server) or the
84
134
  * tree is empty.
@@ -98,7 +148,13 @@ function collectValuesSnapshot(app) {
98
148
  catch {
99
149
  return [];
100
150
  }
101
- // Group leaves by (context, source) so we emit one delta per group.
151
+ // Build $source source object lookup from the top-level sources tree so
152
+ // each synthetic update can carry the correct source.label for attribution.
153
+ const sourceLookup = buildSourceLookup(tree);
154
+ // Group leaves by (context, source, timestamp) so each distinct measurement
155
+ // time gets its own update. This preserves per-path timestamps: paths from
156
+ // the same source that were last updated at different times (e.g. GPS vs
157
+ // autopilot settings) are not collapsed under the same (latest) timestamp.
102
158
  const grouped = new Map();
103
159
  for (const contextGroup of Object.keys(tree)) {
104
160
  if (SK_NON_CONTEXT_KEYS.has(contextGroup)) {
@@ -115,13 +171,23 @@ function collectValuesSnapshot(app) {
115
171
  }
116
172
  const context = `${contextGroup}.${contextId}`;
117
173
  walkValues(contextNode, [], (leaf) => {
118
- const key = `${context}|${leaf.source ?? ""}`;
174
+ // Skip values that this plugin injected from remote instances.
175
+ // SK stores them under "signalk-edge-link.*" $source keys. Including
176
+ // them in the snapshot would loop remote data back to its origin and
177
+ // propagate wrong source labels (the fallback label derived from the
178
+ // "signalk-edge-link" prefix is never the original sensor label).
179
+ // Live streaming handles relay correctly via subscription callbacks,
180
+ // which SK populates with the full source object automatically.
181
+ const src = leaf.source ?? "";
182
+ if (src === "signalk-edge-link" ||
183
+ src.startsWith("signalk-edge-link.") ||
184
+ src.startsWith("signalk-edge-link:")) {
185
+ return;
186
+ }
187
+ const key = `${context}|${leaf.source ?? ""}|${leaf.timestamp}`;
119
188
  const existing = grouped.get(key);
120
189
  if (existing) {
121
190
  existing.values.push({ path: leaf.path, value: leaf.value });
122
- if (leaf.timestamp > existing.timestamp) {
123
- existing.timestamp = leaf.timestamp;
124
- }
125
191
  }
126
192
  else {
127
193
  grouped.set(key, {
@@ -145,6 +211,10 @@ function collectValuesSnapshot(app) {
145
211
  };
146
212
  if (entry.source) {
147
213
  update.$source = entry.source;
214
+ const sourceObj = resolveSource(entry.source, sourceLookup);
215
+ if (sourceObj) {
216
+ update.source = sourceObj;
217
+ }
148
218
  }
149
219
  deltas.push({ context: entry.context, updates: [update] });
150
220
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-edge-link",
3
- "version": "2.6.1",
3
+ "version": "2.6.2",
4
4
  "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
@@ -1 +1 @@
1
- {"version":3,"file":"982.fb1b6560eada159d88ee.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\" />\n\nexport const MANAGEMENT_TOKEN_ERROR_MESSAGE = \"Management token required/invalid.\";\n\ninterface AuthConfig {\n token: string | null;\n localStorageKey: string;\n queryParam: string;\n includeTokenInQuery: boolean;\n headerMode: string;\n}\n\ndeclare global {\n interface Window {\n __EDGE_LINK_AUTH__?: Partial<AuthConfig>;\n }\n}\n\nconst DEFAULT_AUTH_CONFIG: AuthConfig = {\n token: null,\n localStorageKey: \"signalkEdgeLinkManagementToken\",\n queryParam: \"edgeLinkToken\",\n // Default to false: query-parameter tokens leak into browser history, server\n // access logs, and Referer headers. Set includeTokenInQuery: true in\n // window.__EDGE_LINK_AUTH__ only when you explicitly need URL-based auth.\n includeTokenInQuery: false,\n headerMode: \"both\"\n};\n\nfunction readRuntimeAuthConfig(): AuthConfig {\n if (typeof window === \"undefined\") {\n return DEFAULT_AUTH_CONFIG;\n }\n\n const runtime = window.__EDGE_LINK_AUTH__;\n if (!runtime || typeof runtime !== \"object\") {\n return DEFAULT_AUTH_CONFIG;\n }\n\n return { ...DEFAULT_AUTH_CONFIG, ...runtime };\n}\n\nfunction resolveToken(config: AuthConfig): string {\n if (config.token) {\n return String(config.token).trim();\n }\n\n if (typeof window === \"undefined\") {\n return \"\";\n }\n\n // SECURITY NOTE: Query parameter tokens can leak into browser history, server\n // access logs, and Referer headers. Prefer localStorage or\n // window.__EDGE_LINK_AUTH__.token for production deployments. Set\n // includeTokenInQuery: false in __EDGE_LINK_AUTH__ to disable this path.\n if (config.includeTokenInQuery && config.queryParam) {\n const tokenFromQuery = new URLSearchParams(window.location.search).get(config.queryParam);\n if (tokenFromQuery) {\n return tokenFromQuery.trim();\n }\n }\n\n if (config.localStorageKey && window.localStorage) {\n const tokenFromStorage = window.localStorage.getItem(config.localStorageKey);\n if (tokenFromStorage) {\n return tokenFromStorage.trim();\n }\n }\n\n return \"\";\n}\n\nfunction attachAuthHeaders(headers: Headers, token: string, headerMode: string): Headers {\n if (!token) {\n return headers;\n }\n\n const normalizedMode = (headerMode || \"both\").toLowerCase();\n if (\n normalizedMode === \"x-edge-link-token\" ||\n normalizedMode === \"token\" ||\n normalizedMode === \"both\"\n ) {\n headers.set(\"X-Edge-Link-Token\", token);\n }\n if (\n normalizedMode === \"authorization\" ||\n normalizedMode === \"bearer\" ||\n normalizedMode === \"both\"\n ) {\n headers.set(\"Authorization\", `Bearer ${token}`);\n }\n return headers;\n}\n\nexport function getAuthToken(): string {\n const config = readRuntimeAuthConfig();\n return resolveToken(config);\n}\n\nexport function getTokenHelpText(): string {\n const config = readRuntimeAuthConfig();\n const modeText =\n config.headerMode && String(config.headerMode).toLowerCase() === \"authorization\"\n ? \"Authorization: Bearer <token>\"\n : config.headerMode && String(config.headerMode).toLowerCase() === \"x-edge-link-token\"\n ? \"X-Edge-Link-Token\"\n : \"X-Edge-Link-Token and Authorization: Bearer <token>\";\n\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.`;\n}\n\nexport function apiFetch(input: string | Request, init: RequestInit = {}): Promise<Response> {\n const config = readRuntimeAuthConfig();\n const token = resolveToken(config);\n const headers = new Headers(init.headers || {});\n attachAuthHeaders(headers, token, config.headerMode);\n\n return fetch(input, {\n ...init,\n headers\n });\n}\n","\"use strict\";\n\n/**\n * Shared crypto constants that must stay in sync between the backend crypto\n * module and any UI copy that describes key-derivation behaviour. Kept under\n * `src/shared/` so both the server-side build and the webapp bundle can\n * reference the same value.\n */\n\n/**\n * PBKDF2-SHA256 iteration count used by {@link deriveKeyFromPassphrase} and\n * by the opt-in 32-char ASCII key stretching path in {@link normalizeKey}.\n *\n * Tuned to the NIST SP 800-132 recommendation (≥ 600,000) and takes roughly\n * ~300 ms on modern server hardware. The derived key is cached per-process\n * so the cost is paid at most once per unique (key, salt) pair.\n */\nexport const PBKDF2_ITERATIONS = 600_000;\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\n/**\r\n * v1-only ping monitor fields. v2/v3 derive RTT from HEARTBEAT/ACK exchanges\r\n * inside the reliable pipeline, so the external ping monitor (and these\r\n * fields) is not used for protocolVersion >= 2.\r\n */\r\nexport const v1ClientPingProperties: Record<string, SchemaFragment> = {\r\n testAddress: {\r\n type: \"string\",\r\n title: \"Connectivity Test Address (v1 only)\",\r\n description: \"Host used for reachability checks (e.g. 8.8.8.8). v1 only.\",\r\n default: \"127.0.0.1\"\r\n },\r\n testPort: {\r\n type: \"number\",\r\n title: \"Connectivity Test Port (v1 only)\",\r\n description: \"Port used for reachability checks (e.g. 53, 80, or 443). v1 only.\",\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, v1 only)\",\r\n description: \"Frequency of network reachability checks. v1 only.\",\r\n default: 1,\r\n minimum: 0.1,\r\n maximum: 60\r\n }\r\n};\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 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 requestFullStatusOnRestartProperty: SchemaFragment = {\r\n type: \"boolean\",\r\n title: \"Request Full Status on Server Start (v2/v3 only)\",\r\n description:\r\n \"When enabled, the server sends a request to each client on first contact asking it to replay its complete current values snapshot. This rebuilds the server's state immediately after a restart instead of waiting for incremental deltas to arrive.\",\r\n default: false\r\n};\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 requestFullStatusOnRestart: requestFullStatusOnRestartProperty,\r\n reliability: serverReliabilityProperty\r\n }\r\n },\r\n {\r\n properties: {\r\n serverType: { enum: [\"client\"] },\r\n ...clientTransportProperties,\r\n ...v1ClientPingProperties,\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 // testAddress/testPort/pingIntervalTime are validated as v1-only by\r\n // validateConnectionConfig — they are exposed in the schema so\r\n // legacy v1 clients can still set them, but they are not required\r\n // because v2/v3 clients omit them entirely.\r\n required: [\"udpAddress\"]\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\");\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 } else {\r\n // v1 client only: external ping monitor for RTT. v2/v3 measures RTT\r\n // via HEARTBEAT, so these fields are removed entirely from the schema.\r\n Object.assign(props, v1ClientPingProperties);\r\n required.push(\"testAddress\", \"testPort\");\r\n }\r\n } else if (isReliableProtocol) {\r\n props.requestFullStatusOnRestart = requestFullStatusOnRestartProperty;\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// `connectionId` is persisted so redacted secrets can survive identity edits.\r\n\r\nlet _idSeq = 0;\r\nfunction makeId(): string {\r\n return `skel-${Date.now()}-${++_idSeq}`;\r\n}\r\n\r\n// ── Types ─────────────────────────────────────────────────────────────────────\r\n\r\ninterface ConnectionData {\r\n _id: string;\r\n connectionId?: 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\ntype ConnectionFormData = Partial<ConnectionData> & Record<string, unknown>;\r\n\r\ninterface ConnectionFormChangeEvent {\r\n formData?: ConnectionFormData;\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 const id = makeId();\r\n return {\r\n _id: id,\r\n connectionId: id,\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 const id = makeId();\r\n return {\r\n _id: id,\r\n connectionId: id,\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 const connectionId =\r\n typeof conn.connectionId === \"string\" && conn.connectionId.trim()\r\n ? conn.connectionId.trim()\r\n : conn._id || makeId();\r\n return {\r\n ...conn,\r\n _id: conn._id || connectionId,\r\n connectionId\r\n } as ConnectionData;\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\") {\r\n return JSON.stringify(value);\r\n }\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) {\r\n return false;\r\n }\r\n for (const k of aKeys) {\r\n if (!Object.prototype.hasOwnProperty.call(b, k)) {\r\n return false;\r\n }\r\n const av = a[k];\r\n const bv = b[k];\r\n if (av === bv) {\r\n continue;\r\n }\r\n if (av !== null && bv !== null && typeof av === \"object\" && typeof bv === \"object\") {\r\n if (stableStringify(av) !== stableStringify(bv)) {\r\n return false;\r\n }\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\",\r\n \"serverType\",\r\n \"udpAddress\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"protocolVersion\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"testAddress\",\r\n \"testPort\",\r\n \"pingIntervalTime\",\r\n \"helloMessageSender\",\r\n \"heartbeatInterval\",\r\n \"reliability\",\r\n \"congestionControl\",\r\n \"bonding\",\r\n \"skipOwnData\",\r\n \"enableNotifications\",\r\n \"alertThresholds\"\r\n ],\r\n secretKey: {\r\n \"ui:widget\": \"password\",\r\n \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\"\r\n },\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\",\r\n \"serverType\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"protocolVersion\",\r\n \"requestFullStatusOnRestart\",\r\n \"reliability\"\r\n ],\r\n secretKey: {\r\n \"ui:widget\": \"password\",\r\n \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\"\r\n },\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 = [\r\n \"name\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"protocolVersion\"\r\n];\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({\r\n conn,\r\n index,\r\n totalCount,\r\n expanded,\r\n onToggle,\r\n onChange,\r\n onRemove\r\n}: 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: ConnectionFormChangeEvent) {\r\n const next = e.formData;\r\n if (!next) {\r\n return;\r\n }\r\n if (next.serverType && next.serverType !== conn.serverType) {\r\n const base =\r\n next.serverType === \"server\"\r\n ? defaultServerConnection(next.name)\r\n : defaultClientConnection(next.name);\r\n const merged: ConnectionData = {\r\n ...base,\r\n _id: conn._id,\r\n connectionId: conn.connectionId || conn._id\r\n };\r\n for (const k of SHARED_FIELDS) {\r\n if (next[k] !== undefined) {\r\n (merged as Record<string, unknown>)[k] = next[k];\r\n }\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 = {\r\n ...(next as Omit<ConnectionData, \"_id\">),\r\n _id: conn._id,\r\n connectionId: next.connectionId || conn.connectionId || conn._id\r\n };\r\n // v1-only ping monitor fields must be absent on v2/v3 clients (the\r\n // backend validator rejects them). Drop them when the user toggles the\r\n // protocol version up so a v1 → v2 upgrade doesn't leave stale fields\r\n // attached to the form data.\r\n const isClientNow = proposed.serverType !== \"server\";\r\n if (isClientNow && (proposed.protocolVersion ?? 1) >= 2) {\r\n delete proposed.testAddress;\r\n delete proposed.testPort;\r\n delete proposed.pingIntervalTime;\r\n }\r\n const { _id: _aId, ...a } = proposed;\r\n const { _id: _bId, ...b } = conn;\r\n if (connectionsEqual(a, b)) {\r\n return;\r\n }\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) => {\r\n e.stopPropagation();\r\n onRemove();\r\n }}\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) {\r\n throw new Error(`HTTP ${res.status}: ${res.statusText}`);\r\n }\r\n const body = await res.json();\r\n if (!body.success) {\r\n throw new Error(body.error || \"Failed to load configuration\");\r\n }\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\">) =>\r\n withSchemaDefaults(withId(c))\r\n );\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(\r\n typeof cfg.managementApiToken === \"string\" ? cfg.managementApiToken : \"\"\r\n );\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.filter((c) => c.serverType === \"server\").map((c) => c.udpPort);\r\n const duplicatePortSet = new Set(serverPorts.filter((p, i) => serverPorts.indexOf(p) !== i));\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) =>\r\n prevExpanded !== null && prevExpanded >= idx && prevExpanded > 0\r\n ? prevExpanded - 1\r\n : prevExpanded\r\n );\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) {\r\n return;\r\n }\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 }) => ({\r\n ...rest,\r\n connectionId:\r\n typeof rest.connectionId === \"string\" && rest.connectionId.trim()\r\n ? rest.connectionId.trim()\r\n : _id\r\n }));\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({\r\n type: \"success\",\r\n message: body.message || \"Configuration saved. Plugin restarting...\"\r\n });\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\r\n className={`skel-alert skel-alert-${saveStatus.type === \"saving\" ? \"saving\" : saveStatus.type === \"success\" ? \"success\" : \"error\"}`}\r\n >\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) => {\r\n setManagementApiToken(e.target.value);\r\n markDirty();\r\n }}\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 <code>SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN</code>{\" \"}\r\n environment variable (env var takes 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) => {\r\n setRequireManagementApiToken(e.target.checked);\r\n markDirty();\r\n }}\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\r\n 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","v1ClientPingProperties","testAddress","testPort","pingIntervalTime","clientTransportProperties","udpAddress","helloMessageSender","heartbeatInterval","clientReliabilityProperty","properties","retransmitQueueSize","maxRetransmits","retransmitMaxAge","retransmitMinAge","retransmitRttMultiplier","ackIdleDrainAge","forceDrainAfterAckIdle","forceDrainAfterMs","recoveryBurstEnabled","recoveryBurstSize","recoveryBurstIntervalMs","recoveryAckGapMs","requestFullStatusOnRestartProperty","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","requestFullStatusOnRestart","API_BASE","_idSeq","makeId","Date","now","defaultClientConnection","id","_id","connectionId","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","placeholder","target","autoComplete","checked","idx","key","prev","toggleExpand","data","updateConnection","_","prevExpanded","removeConnection","has","color","fontSize","fontWeight"],"sourceRoot":""}
1
+ {"version":3,"file":"982.fb1b6560eada159d88ee.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\" />\n\nexport const MANAGEMENT_TOKEN_ERROR_MESSAGE = \"Management token required/invalid.\";\n\ninterface AuthConfig {\n token: string | null;\n localStorageKey: string;\n queryParam: string;\n includeTokenInQuery: boolean;\n headerMode: string;\n}\n\ndeclare global {\n interface Window {\n __EDGE_LINK_AUTH__?: Partial<AuthConfig>;\n }\n}\n\nconst DEFAULT_AUTH_CONFIG: AuthConfig = {\n token: null,\n localStorageKey: \"signalkEdgeLinkManagementToken\",\n queryParam: \"edgeLinkToken\",\n // Default to false: query-parameter tokens leak into browser history, server\n // access logs, and Referer headers. Set includeTokenInQuery: true in\n // window.__EDGE_LINK_AUTH__ only when you explicitly need URL-based auth.\n includeTokenInQuery: false,\n headerMode: \"both\"\n};\n\nfunction readRuntimeAuthConfig(): AuthConfig {\n if (typeof window === \"undefined\") {\n return DEFAULT_AUTH_CONFIG;\n }\n\n const runtime = window.__EDGE_LINK_AUTH__;\n if (!runtime || typeof runtime !== \"object\") {\n return DEFAULT_AUTH_CONFIG;\n }\n\n return { ...DEFAULT_AUTH_CONFIG, ...runtime };\n}\n\nfunction resolveToken(config: AuthConfig): string {\n if (config.token) {\n return String(config.token).trim();\n }\n\n if (typeof window === \"undefined\") {\n return \"\";\n }\n\n // SECURITY NOTE: Query parameter tokens can leak into browser history, server\n // access logs, and Referer headers. Prefer localStorage or\n // window.__EDGE_LINK_AUTH__.token for production deployments. Set\n // includeTokenInQuery: false in __EDGE_LINK_AUTH__ to disable this path.\n if (config.includeTokenInQuery && config.queryParam) {\n const tokenFromQuery = new URLSearchParams(window.location.search).get(config.queryParam);\n if (tokenFromQuery) {\n return tokenFromQuery.trim();\n }\n }\n\n if (config.localStorageKey && window.localStorage) {\n const tokenFromStorage = window.localStorage.getItem(config.localStorageKey);\n if (tokenFromStorage) {\n return tokenFromStorage.trim();\n }\n }\n\n return \"\";\n}\n\nfunction attachAuthHeaders(headers: Headers, token: string, headerMode: string): Headers {\n if (!token) {\n return headers;\n }\n\n const normalizedMode = (headerMode || \"both\").toLowerCase();\n if (\n normalizedMode === \"x-edge-link-token\" ||\n normalizedMode === \"token\" ||\n normalizedMode === \"both\"\n ) {\n headers.set(\"X-Edge-Link-Token\", token);\n }\n if (\n normalizedMode === \"authorization\" ||\n normalizedMode === \"bearer\" ||\n normalizedMode === \"both\"\n ) {\n headers.set(\"Authorization\", `Bearer ${token}`);\n }\n return headers;\n}\n\nexport function getAuthToken(): string {\n const config = readRuntimeAuthConfig();\n return resolveToken(config);\n}\n\nexport function getTokenHelpText(): string {\n const config = readRuntimeAuthConfig();\n const modeText =\n config.headerMode && String(config.headerMode).toLowerCase() === \"authorization\"\n ? \"Authorization: Bearer <token>\"\n : config.headerMode && String(config.headerMode).toLowerCase() === \"x-edge-link-token\"\n ? \"X-Edge-Link-Token\"\n : \"X-Edge-Link-Token and Authorization: Bearer <token>\";\n\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.`;\n}\n\nexport function apiFetch(input: string | Request, init: RequestInit = {}): Promise<Response> {\n const config = readRuntimeAuthConfig();\n const token = resolveToken(config);\n const headers = new Headers(init.headers || {});\n attachAuthHeaders(headers, token, config.headerMode);\n\n return fetch(input, {\n ...init,\n headers\n });\n}\n","\"use strict\";\n\n/**\n * Shared crypto constants that must stay in sync between the backend crypto\n * module and any UI copy that describes key-derivation behaviour. Kept under\n * `src/shared/` so both the server-side build and the webapp bundle can\n * reference the same value.\n */\n\n/**\n * PBKDF2-SHA256 iteration count used by {@link deriveKeyFromPassphrase} and\n * by the opt-in 32-char ASCII key stretching path in {@link normalizeKey}.\n *\n * Tuned to the NIST SP 800-132 recommendation (≥ 600,000) and takes roughly\n * ~300 ms on modern server hardware. The derived key is cached per-process\n * so the cost is paid at most once per unique (key, salt) pair.\n */\nexport const PBKDF2_ITERATIONS = 600_000;\n","/**\n * Single source of truth for the connection configuration schema.\n *\n * Both the backend `plugin.schema` in `src/index.ts` (used by Signal K's\n * default admin UI and served via the `/plugin-schema` route for default\n * extraction) and the frontend RJSF form in\n * `src/webapp/components/PluginConfigurationPanel.tsx` consume the fragments\n * exported here. Adding or editing a connection field must happen in this\n * module; the two consumers then render it identically.\n *\n * The fragments are typed as plain `Record<string, unknown>` so they can be\n * imported by both the server-side TypeScript build and the webapp build\n * without pulling `@rjsf/utils` into the server bundle. The webapp casts\n * results to `RJSFSchema` at call sites.\n */\n\nimport { PBKDF2_ITERATIONS } from \"./crypto-constants\";\n\nexport type SchemaFragment = Record<string, unknown>;\n\n// ── Common (client + server) ──────────────────────────────────────────────────\n\nexport const commonConnectionProperties: Record<string, SchemaFragment> = {\n name: {\n type: \"string\",\n title: \"Connection Name\",\n description:\n \"Human-readable label for this connection (e.g. 'Shore Server', 'Sat Client'). Used to namespace config files and Signal K metrics paths.\",\n default: \"connection\",\n maxLength: 40\n },\n serverType: {\n type: \"string\",\n title: \"Operation Mode\",\n description: \"Select Server to receive data, or Client to send data.\",\n default: \"client\",\n oneOf: [\n { const: \"server\", title: \"Server Mode – Receive Data\" },\n { const: \"client\", title: \"Client Mode – Send Data\" }\n ]\n },\n udpPort: {\n type: \"number\",\n title: \"UDP Port\",\n description: \"UDP port for data transmission (must match on both ends).\",\n default: 4446,\n minimum: 1024,\n maximum: 65535\n },\n secretKey: {\n type: \"string\",\n title: \"Encryption Key\",\n description:\n \"32-byte secret key: 32-character ASCII, 64-character hex, or 44-character base64.\",\n minLength: 32,\n maxLength: 64,\n pattern: \"^(?:.{32}|[0-9a-fA-F]{64}|[A-Za-z0-9+/]{43}=?)$\"\n },\n stretchAsciiKey: {\n type: \"boolean\",\n title: \"Stretch 32-char ASCII Key (PBKDF2)\",\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.`,\n default: false\n },\n useMsgpack: {\n type: \"boolean\",\n title: \"Use MessagePack\",\n description: \"Binary serialization for smaller payloads (must match on both ends).\",\n default: false\n },\n usePathDictionary: {\n type: \"boolean\",\n title: \"Use Path Dictionary\",\n description: \"Encode paths as numeric IDs for bandwidth savings (must match on both ends).\",\n default: false\n },\n protocolVersion: {\n type: \"number\",\n title: \"Protocol Version\",\n description:\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.\",\n default: 1,\n oneOf: [\n { const: 1, title: \"v1 – Standard encrypted UDP\" },\n { const: 2, title: \"v2 – Reliability, congestion control, bonding, metrics\" },\n { const: 3, title: \"v3 - v2 features with authenticated control packets\" }\n ]\n }\n};\n\n// ── Client-only transport / reachability fields ───────────────────────────────\n\n/**\n * v1-only ping monitor fields. v2/v3 derive RTT from HEARTBEAT/ACK exchanges\n * inside the reliable pipeline, so the external ping monitor (and these\n * fields) is not used for protocolVersion >= 2.\n */\nexport const v1ClientPingProperties: Record<string, SchemaFragment> = {\n testAddress: {\n type: \"string\",\n title: \"Connectivity Test Address (v1 only)\",\n description: \"Host used for reachability checks (e.g. 8.8.8.8). v1 only.\",\n default: \"127.0.0.1\"\n },\n testPort: {\n type: \"number\",\n title: \"Connectivity Test Port (v1 only)\",\n description: \"Port used for reachability checks (e.g. 53, 80, or 443). v1 only.\",\n default: 80,\n minimum: 1,\n maximum: 65535\n },\n pingIntervalTime: {\n type: \"number\",\n title: \"Check Interval (minutes, v1 only)\",\n description: \"Frequency of network reachability checks. v1 only.\",\n default: 1,\n minimum: 0.1,\n maximum: 60\n }\n};\n\nexport const clientTransportProperties: Record<string, SchemaFragment> = {\n udpAddress: {\n type: \"string\",\n title: \"Server Address\",\n description: \"IP address or hostname of the remote Signal K endpoint.\",\n default: \"127.0.0.1\"\n },\n helloMessageSender: {\n type: \"integer\",\n title: \"Heartbeat Interval (seconds)\",\n description: \"Send periodic heartbeat messages to keep NAT/firewall mappings alive.\",\n default: 60,\n minimum: 10,\n maximum: 3600\n },\n heartbeatInterval: {\n type: \"number\",\n title: \"NAT Keepalive Heartbeat Interval (ms)\",\n description:\n \"v2/v3 only. How often to send UDP heartbeat packets for NAT traversal. Typical NAT timeouts range from 30s to 120s.\",\n default: 25000,\n minimum: 5000,\n maximum: 120000\n }\n};\n\n// ── v2/v3 reliability (client pipeline — retransmit queue) ────────────────────\n\nexport const clientReliabilityProperty: SchemaFragment = {\n type: \"object\",\n title: \"Reliability Settings (v2/v3 only)\",\n description:\n \"Requires Protocol v2 or v3. Controls retransmit queue behavior and packet retry limits.\",\n properties: {\n retransmitQueueSize: {\n type: \"number\",\n title: \"Retransmit Queue Size\",\n description: \"Maximum number of sent packets stored for potential retransmission.\",\n default: 5000,\n minimum: 100,\n maximum: 50000\n },\n maxRetransmits: {\n type: \"number\",\n title: \"Max Retransmit Attempts\",\n description: \"Maximum resend attempts before a packet is dropped from the retransmit queue.\",\n default: 3,\n minimum: 1,\n maximum: 20\n },\n retransmitMaxAge: {\n type: \"number\",\n title: \"Retransmit Max Age (ms)\",\n description: \"Expire stale unacknowledged packets older than this age.\",\n default: 120000,\n minimum: 1000,\n maximum: 300000\n },\n retransmitMinAge: {\n type: \"number\",\n title: \"Retransmit Min Age (ms)\",\n description: \"Minimum packet age before expiration is allowed.\",\n default: 10000,\n minimum: 200,\n maximum: 30000\n },\n retransmitRttMultiplier: {\n type: \"number\",\n title: \"RTT Expiry Multiplier\",\n description: \"Dynamic expiry age = RTT × this multiplier.\",\n default: 12,\n minimum: 2,\n maximum: 20\n },\n ackIdleDrainAge: {\n type: \"number\",\n title: \"ACK Idle Drain Age (ms)\",\n description: \"If ACKs are idle longer than this, expiry becomes more aggressive.\",\n default: 20000,\n minimum: 500,\n maximum: 30000\n },\n forceDrainAfterAckIdle: {\n type: \"boolean\",\n title: \"Force Drain After ACK Idle\",\n description: \"When enabled, clear retransmit queue if no ACKs arrive for too long.\",\n default: false\n },\n forceDrainAfterMs: {\n type: \"number\",\n title: \"Force Drain Timeout (ms)\",\n description: \"ACK idle duration before force-draining retransmit queue to zero.\",\n default: 45000,\n minimum: 2000,\n maximum: 120000\n },\n recoveryBurstEnabled: {\n type: \"boolean\",\n title: \"Recovery Burst Enabled\",\n description: \"When ACKs return after outage, rapidly retransmit queued packets to catch up.\",\n default: true\n },\n recoveryBurstSize: {\n type: \"number\",\n title: \"Recovery Burst Size\",\n description: \"Max queued packets to retransmit per recovery burst cycle.\",\n default: 100,\n minimum: 10,\n maximum: 1000\n },\n recoveryBurstIntervalMs: {\n type: \"number\",\n title: \"Recovery Burst Interval (ms)\",\n description: \"Interval between recovery burst cycles while backlog exists.\",\n default: 200,\n minimum: 50,\n maximum: 5000\n },\n recoveryAckGapMs: {\n type: \"number\",\n title: \"Recovery ACK Gap (ms)\",\n description: \"Minimum ACK silence before triggering fast recovery bursts.\",\n default: 4000,\n minimum: 500,\n maximum: 120000\n }\n }\n};\n\n// ── v2/v3 reliability (server pipeline — ACK/NAK timing) ──────────────────────\n\nexport const requestFullStatusOnRestartProperty: SchemaFragment = {\n type: \"boolean\",\n title: \"Request Full Status on Server Start (v2/v3 only)\",\n description:\n \"When enabled, the server sends a request to each client on first contact asking it to replay its complete current values snapshot. This rebuilds the server's state immediately after a restart instead of waiting for incremental deltas to arrive.\",\n default: false\n};\n\nexport const serverReliabilityProperty: SchemaFragment = {\n type: \"object\",\n title: \"Reliability Settings (v2/v3 only)\",\n description: \"Requires Protocol v2 or v3. Controls ACK/NAK timing for reliable delivery.\",\n properties: {\n ackInterval: {\n type: \"number\",\n title: \"ACK Interval (ms)\",\n description: \"How often server sends cumulative ACK updates.\",\n default: 100,\n minimum: 20,\n maximum: 5000\n },\n ackResendInterval: {\n type: \"number\",\n title: \"ACK Resend Interval (ms)\",\n description: \"Re-send duplicate ACK periodically to recover from lost ACK packets.\",\n default: 1000,\n minimum: 100,\n maximum: 10000\n },\n nakTimeout: {\n type: \"number\",\n title: \"NAK Timeout (ms)\",\n description: \"Delay before requesting retransmission for missing sequence numbers.\",\n default: 100,\n minimum: 20,\n maximum: 5000\n }\n }\n};\n\n// ── v2/v3 congestion control (client) ─────────────────────────────────────────\n\nexport const congestionControlProperty: SchemaFragment = {\n type: \"object\",\n title: \"Dynamic Congestion Control (v2/v3 only)\",\n description:\n \"Requires Protocol v2 or v3. AIMD algorithm to dynamically adjust send rate based on network conditions.\",\n properties: {\n enabled: {\n type: \"boolean\",\n title: \"Enable Congestion Control\",\n description: \"Automatically adjust delta timer based on RTT and packet loss.\",\n default: false\n },\n targetRTT: {\n type: \"number\",\n title: \"Target RTT (ms)\",\n description: \"RTT threshold above which send rate is reduced.\",\n default: 200,\n minimum: 50,\n maximum: 2000\n },\n nominalDeltaTimer: {\n type: \"number\",\n title: \"Nominal Delta Timer (ms)\",\n description: \"Preferred steady-state send interval.\",\n default: 1000,\n minimum: 100,\n maximum: 10000\n },\n minDeltaTimer: {\n type: \"number\",\n title: \"Minimum Delta Timer (ms)\",\n description: \"Fastest allowed send interval.\",\n default: 100,\n minimum: 50,\n maximum: 1000\n },\n maxDeltaTimer: {\n type: \"number\",\n title: \"Maximum Delta Timer (ms)\",\n description: \"Slowest allowed send interval.\",\n default: 5000,\n minimum: 1000,\n maximum: 30000\n }\n }\n};\n\n// ── v2/v3 connection bonding (client) ─────────────────────────────────────────\n\nexport const bondingProperty: SchemaFragment = {\n type: \"object\",\n title: \"Connection Bonding (v2/v3 only)\",\n description:\n \"Requires Protocol v2 or v3. Dual-link bonding with automatic failover between primary and backup connections.\",\n properties: {\n enabled: {\n type: \"boolean\",\n title: \"Enable Connection Bonding\",\n description: \"Enable dual-link bonding with automatic failover.\",\n default: false\n },\n mode: {\n type: \"string\",\n title: \"Bonding Mode\",\n description: \"Bonding operating mode.\",\n default: \"main-backup\",\n oneOf: [\n {\n const: \"main-backup\",\n title: \"Main/Backup – Failover to backup when primary degrades\"\n }\n ]\n },\n primary: {\n type: \"object\",\n title: \"Primary Link\",\n description: \"Primary connection (e.g. LTE modem).\",\n properties: {\n address: { type: \"string\", title: \"Server Address\", default: \"127.0.0.1\" },\n port: {\n type: \"number\",\n title: \"UDP Port\",\n default: 4446,\n minimum: 1024,\n maximum: 65535\n },\n interface: {\n type: \"string\",\n title: \"Bind Interface (optional)\",\n description: \"Network interface IP to bind to.\"\n }\n }\n },\n backup: {\n type: \"object\",\n title: \"Backup Link\",\n description: \"Backup connection (e.g. Starlink, satellite).\",\n properties: {\n address: { type: \"string\", title: \"Server Address\", default: \"127.0.0.1\" },\n port: {\n type: \"number\",\n title: \"UDP Port\",\n default: 4447,\n minimum: 1024,\n maximum: 65535\n },\n interface: {\n type: \"string\",\n title: \"Bind Interface (optional)\",\n description: \"Network interface IP to bind to.\"\n }\n }\n },\n failover: {\n type: \"object\",\n title: \"Failover Thresholds\",\n description: \"Configure when failover is triggered.\",\n properties: {\n rttThreshold: {\n type: \"number\",\n title: \"RTT Threshold (ms)\",\n default: 500,\n minimum: 100,\n maximum: 5000\n },\n lossThreshold: {\n type: \"number\",\n title: \"Packet Loss Threshold (0-1)\",\n default: 0.1,\n minimum: 0.01,\n maximum: 0.5\n },\n healthCheckInterval: {\n type: \"number\",\n title: \"Health Check Interval (ms)\",\n default: 1000,\n minimum: 500,\n maximum: 10000\n },\n failbackDelay: {\n type: \"number\",\n title: \"Failback Delay (ms)\",\n default: 30000,\n minimum: 5000,\n maximum: 300000\n },\n heartbeatTimeout: {\n type: \"number\",\n title: \"Heartbeat Timeout (ms)\",\n default: 5000,\n minimum: 1000,\n maximum: 30000\n }\n }\n }\n }\n};\n\n// ── Client-only notifications toggle ──────────────────────────────────────────\n\nexport const enableNotificationsProperty: SchemaFragment = {\n type: \"boolean\",\n title: \"Enable Signal K Notifications\",\n description: \"Emit Signal K notifications for alerts and failover events.\",\n default: false\n};\n\n// ── Client-only: skip forwarding plugin-generated data ────────────────────────\n\nexport const skipOwnDataProperty: SchemaFragment = {\n type: \"boolean\",\n title: \"Skip Plugin's Own Data\",\n description:\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.\",\n default: false\n};\n\n// ── v2/v3 monitoring alert thresholds (client) ────────────────────────────────\n\nexport const alertThresholdsProperty: SchemaFragment = {\n type: \"object\",\n title: \"Monitoring Alert Thresholds (v2/v3 only)\",\n description: \"Customize warning/critical thresholds for network monitoring alerts.\",\n properties: {\n rtt: {\n type: \"object\",\n title: \"RTT Thresholds\",\n properties: {\n warning: { type: \"number\", title: \"Warning RTT (ms)\", default: 300 },\n critical: { type: \"number\", title: \"Critical RTT (ms)\", default: 800 }\n }\n },\n packetLoss: {\n type: \"object\",\n title: \"Packet Loss Thresholds\",\n properties: {\n warning: { type: \"number\", title: \"Warning Loss Ratio\", default: 0.03 },\n critical: { type: \"number\", title: \"Critical Loss Ratio\", default: 0.1 }\n }\n },\n retransmitRate: {\n type: \"object\",\n title: \"Retransmit Rate Thresholds\",\n properties: {\n warning: { type: \"number\", title: \"Warning Retransmit Ratio\", default: 0.05 },\n critical: { type: \"number\", title: \"Critical Retransmit Ratio\", default: 0.15 }\n }\n },\n jitter: {\n type: \"object\",\n title: \"Jitter Thresholds\",\n properties: {\n warning: { type: \"number\", title: \"Warning Jitter (ms)\", default: 100 },\n critical: { type: \"number\", title: \"Critical Jitter (ms)\", default: 300 }\n }\n },\n queueDepth: {\n type: \"object\",\n title: \"Queue Depth Thresholds\",\n properties: {\n warning: { type: \"number\", title: \"Warning Queue Depth\", default: 100 },\n critical: { type: \"number\", title: \"Critical Queue Depth\", default: 500 }\n }\n }\n }\n};\n\n// ── Builder consumed by the backend (`plugin.schema` in src/index.ts) ─────────\n\n/**\n * Build the `connections[]` item schema used by Signal K's default admin UI\n * and served via `GET /plugin-schema`. Client-only fields live under\n * `dependencies.serverType.oneOf` so they appear only in client mode.\n */\nexport function buildConnectionItemSchema(): SchemaFragment {\n return {\n type: \"object\",\n title: \"Connection\",\n required: [\"serverType\", \"udpPort\", \"secretKey\"],\n properties: { ...commonConnectionProperties },\n dependencies: {\n serverType: {\n oneOf: [\n {\n properties: {\n serverType: { enum: [\"server\"] },\n requestFullStatusOnRestart: requestFullStatusOnRestartProperty,\n reliability: serverReliabilityProperty\n }\n },\n {\n properties: {\n serverType: { enum: [\"client\"] },\n ...clientTransportProperties,\n ...v1ClientPingProperties,\n reliability: clientReliabilityProperty,\n congestionControl: congestionControlProperty,\n bonding: bondingProperty,\n enableNotifications: enableNotificationsProperty,\n skipOwnData: skipOwnDataProperty,\n alertThresholds: alertThresholdsProperty\n },\n // testAddress/testPort/pingIntervalTime are validated as v1-only by\n // validateConnectionConfig — they are exposed in the schema so\n // legacy v1 clients can still set them, but they are not required\n // because v2/v3 clients omit them entirely.\n required: [\"udpAddress\"]\n }\n ]\n }\n }\n };\n}\n\n// ── Builder consumed by the webapp (PluginConfigurationPanel.tsx) ─────────────\n\n/**\n * Build the flat per-connection schema consumed by the webapp RJSF form.\n * Unlike the backend variant this is a flat object that is rebuilt whenever\n * the user toggles `serverType` or `protocolVersion` so RJSF re-renders with\n * the right subset of fields.\n */\nexport function buildWebappConnectionSchema(\n isClient: boolean,\n protocolVersion: number | undefined\n): SchemaFragment {\n const isReliableProtocol = Number(protocolVersion) >= 2;\n const props: Record<string, SchemaFragment> = { ...commonConnectionProperties };\n const required = [\"serverType\", \"udpPort\", \"secretKey\"];\n\n if (isClient) {\n Object.assign(props, clientTransportProperties);\n props.enableNotifications = enableNotificationsProperty;\n props.skipOwnData = skipOwnDataProperty;\n required.push(\"udpAddress\");\n if (isReliableProtocol) {\n props.reliability = clientReliabilityProperty;\n props.congestionControl = congestionControlProperty;\n props.bonding = bondingProperty;\n props.alertThresholds = alertThresholdsProperty;\n } else {\n // v1 client only: external ping monitor for RTT. v2/v3 measures RTT\n // via HEARTBEAT, so these fields are removed entirely from the schema.\n Object.assign(props, v1ClientPingProperties);\n required.push(\"testAddress\", \"testPort\");\n }\n } else if (isReliableProtocol) {\n props.requestFullStatusOnRestart = requestFullStatusOnRestartProperty;\n props.reliability = serverReliabilityProperty;\n }\n\n return { type: \"object\", required, properties: props };\n}\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// `connectionId` is persisted so redacted secrets can survive identity edits.\r\n\r\nlet _idSeq = 0;\r\nfunction makeId(): string {\r\n return `skel-${Date.now()}-${++_idSeq}`;\r\n}\r\n\r\n// ── Types ─────────────────────────────────────────────────────────────────────\r\n\r\ninterface ConnectionData {\r\n _id: string;\r\n connectionId?: 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\ntype ConnectionFormData = Partial<ConnectionData> & Record<string, unknown>;\r\n\r\ninterface ConnectionFormChangeEvent {\r\n formData?: ConnectionFormData;\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 const id = makeId();\r\n return {\r\n _id: id,\r\n connectionId: id,\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 const id = makeId();\r\n return {\r\n _id: id,\r\n connectionId: id,\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 const connectionId =\r\n typeof conn.connectionId === \"string\" && conn.connectionId.trim()\r\n ? conn.connectionId.trim()\r\n : conn._id || makeId();\r\n return {\r\n ...conn,\r\n _id: conn._id || connectionId,\r\n connectionId\r\n } as ConnectionData;\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\") {\r\n return JSON.stringify(value);\r\n }\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) {\r\n return false;\r\n }\r\n for (const k of aKeys) {\r\n if (!Object.prototype.hasOwnProperty.call(b, k)) {\r\n return false;\r\n }\r\n const av = a[k];\r\n const bv = b[k];\r\n if (av === bv) {\r\n continue;\r\n }\r\n if (av !== null && bv !== null && typeof av === \"object\" && typeof bv === \"object\") {\r\n if (stableStringify(av) !== stableStringify(bv)) {\r\n return false;\r\n }\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\",\r\n \"serverType\",\r\n \"udpAddress\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"protocolVersion\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"testAddress\",\r\n \"testPort\",\r\n \"pingIntervalTime\",\r\n \"helloMessageSender\",\r\n \"heartbeatInterval\",\r\n \"reliability\",\r\n \"congestionControl\",\r\n \"bonding\",\r\n \"skipOwnData\",\r\n \"enableNotifications\",\r\n \"alertThresholds\"\r\n ],\r\n secretKey: {\r\n \"ui:widget\": \"password\",\r\n \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\"\r\n },\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\",\r\n \"serverType\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"protocolVersion\",\r\n \"requestFullStatusOnRestart\",\r\n \"reliability\"\r\n ],\r\n secretKey: {\r\n \"ui:widget\": \"password\",\r\n \"ui:help\": \"Use 32-character ASCII, 64-character hex, or 44-character base64\"\r\n },\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 = [\r\n \"name\",\r\n \"udpPort\",\r\n \"secretKey\",\r\n \"stretchAsciiKey\",\r\n \"useMsgpack\",\r\n \"usePathDictionary\",\r\n \"protocolVersion\"\r\n];\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({\r\n conn,\r\n index,\r\n totalCount,\r\n expanded,\r\n onToggle,\r\n onChange,\r\n onRemove\r\n}: 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: ConnectionFormChangeEvent) {\r\n const next = e.formData;\r\n if (!next) {\r\n return;\r\n }\r\n if (next.serverType && next.serverType !== conn.serverType) {\r\n const base =\r\n next.serverType === \"server\"\r\n ? defaultServerConnection(next.name)\r\n : defaultClientConnection(next.name);\r\n const merged: ConnectionData = {\r\n ...base,\r\n _id: conn._id,\r\n connectionId: conn.connectionId || conn._id\r\n };\r\n for (const k of SHARED_FIELDS) {\r\n if (next[k] !== undefined) {\r\n (merged as Record<string, unknown>)[k] = next[k];\r\n }\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 = {\r\n ...(next as Omit<ConnectionData, \"_id\">),\r\n _id: conn._id,\r\n connectionId: next.connectionId || conn.connectionId || conn._id\r\n };\r\n // v1-only ping monitor fields must be absent on v2/v3 clients (the\r\n // backend validator rejects them). Drop them when the user toggles the\r\n // protocol version up so a v1 → v2 upgrade doesn't leave stale fields\r\n // attached to the form data.\r\n const isClientNow = proposed.serverType !== \"server\";\r\n if (isClientNow && (proposed.protocolVersion ?? 1) >= 2) {\r\n delete proposed.testAddress;\r\n delete proposed.testPort;\r\n delete proposed.pingIntervalTime;\r\n }\r\n const { _id: _aId, ...a } = proposed;\r\n const { _id: _bId, ...b } = conn;\r\n if (connectionsEqual(a, b)) {\r\n return;\r\n }\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) => {\r\n e.stopPropagation();\r\n onRemove();\r\n }}\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) {\r\n throw new Error(`HTTP ${res.status}: ${res.statusText}`);\r\n }\r\n const body = await res.json();\r\n if (!body.success) {\r\n throw new Error(body.error || \"Failed to load configuration\");\r\n }\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\">) =>\r\n withSchemaDefaults(withId(c))\r\n );\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(\r\n typeof cfg.managementApiToken === \"string\" ? cfg.managementApiToken : \"\"\r\n );\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.filter((c) => c.serverType === \"server\").map((c) => c.udpPort);\r\n const duplicatePortSet = new Set(serverPorts.filter((p, i) => serverPorts.indexOf(p) !== i));\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) =>\r\n prevExpanded !== null && prevExpanded >= idx && prevExpanded > 0\r\n ? prevExpanded - 1\r\n : prevExpanded\r\n );\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) {\r\n return;\r\n }\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 }) => ({\r\n ...rest,\r\n connectionId:\r\n typeof rest.connectionId === \"string\" && rest.connectionId.trim()\r\n ? rest.connectionId.trim()\r\n : _id\r\n }));\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({\r\n type: \"success\",\r\n message: body.message || \"Configuration saved. Plugin restarting...\"\r\n });\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\r\n className={`skel-alert skel-alert-${saveStatus.type === \"saving\" ? \"saving\" : saveStatus.type === \"success\" ? \"success\" : \"error\"}`}\r\n >\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) => {\r\n setManagementApiToken(e.target.value);\r\n markDirty();\r\n }}\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 <code>SIGNALK_EDGE_LINK_MANAGEMENT_TOKEN</code>{\" \"}\r\n environment variable (env var takes 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) => {\r\n setRequireManagementApiToken(e.target.checked);\r\n markDirty();\r\n }}\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\r\n 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","v1ClientPingProperties","testAddress","testPort","pingIntervalTime","clientTransportProperties","udpAddress","helloMessageSender","heartbeatInterval","clientReliabilityProperty","properties","retransmitQueueSize","maxRetransmits","retransmitMaxAge","retransmitMinAge","retransmitRttMultiplier","ackIdleDrainAge","forceDrainAfterAckIdle","forceDrainAfterMs","recoveryBurstEnabled","recoveryBurstSize","recoveryBurstIntervalMs","recoveryAckGapMs","requestFullStatusOnRestartProperty","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","requestFullStatusOnRestart","API_BASE","_idSeq","makeId","Date","now","defaultClientConnection","id","_id","connectionId","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","placeholder","target","autoComplete","checked","idx","key","prev","toggleExpand","data","updateConnection","_","prevExpanded","removeConnection","has","color","fontSize","fontWeight"],"sourceRoot":""}