signalk-edge-link 2.6.1 → 2.6.3
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 +39 -16
- package/lib/instance.js +28 -2
- package/lib/pipeline-v2-server.js +16 -1
- package/lib/values-snapshot.js +89 -9
- package/package.json +1 -1
- package/public/982.fb1b6560eada159d88ee.js.map +1 -1
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
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -203,6 +209,23 @@ module.exports = function createPlugin(app) {
|
|
|
203
209
|
setStatus(`Startup failed: ${startError instanceof Error ? startError.message : String(startError)}`);
|
|
204
210
|
return;
|
|
205
211
|
}
|
|
212
|
+
// Wire up FULL_STATUS_REQUEST cascade: when a client-mode instance receives
|
|
213
|
+
// a FULL_STATUS_REQUEST from its upstream server, it should also forward the
|
|
214
|
+
// request to all downstream clients connected to any co-located server-mode
|
|
215
|
+
// instances. This propagates the request down the chain (Cloud → Proxy → Boat)
|
|
216
|
+
// so one-shot startup values from the furthest-downstream node are re-sent.
|
|
217
|
+
const allStarted = [...instances.values()];
|
|
218
|
+
const serverInsts = allStarted.filter((inst) => inst.isServerMode());
|
|
219
|
+
const clientInsts = allStarted.filter((inst) => !inst.isServerMode());
|
|
220
|
+
if (serverInsts.length > 0 && clientInsts.length > 0) {
|
|
221
|
+
for (const clientInst of clientInsts) {
|
|
222
|
+
clientInst.setFullStatusCascadeHandler(() => {
|
|
223
|
+
for (const serverInst of serverInsts) {
|
|
224
|
+
serverInst.requestFullStatusFromAllClients();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
206
229
|
// Initial status aggregation after all instances report their status
|
|
207
230
|
updateAggregatedStatus();
|
|
208
231
|
};
|
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 ────────────────────────────────────────────────────
|
|
@@ -379,6 +379,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
379
379
|
/** Minimum gap between server-initiated full-status replays. Prevents a
|
|
380
380
|
* restarting or misconfigured server from flooding the link. */
|
|
381
381
|
const FULL_STATUS_REQUEST_RATE_LIMIT_MS = 10000;
|
|
382
|
+
/**
|
|
383
|
+
* Optional callback invoked after this (client-mode) instance handles a
|
|
384
|
+
* FULL_STATUS_REQUEST. Used in multi-hop chains to cascade the request to
|
|
385
|
+
* any downstream clients connected to a co-located server-mode instance.
|
|
386
|
+
*/
|
|
387
|
+
let fullStatusCascadeHandler = null;
|
|
382
388
|
/** Server asked for a full values snapshot (FULL_STATUS_REQUEST control
|
|
383
389
|
* packet). Replays the entire current Signal K tree to the server.
|
|
384
390
|
* Rate-limited to prevent replay floods across rapid server restarts. */
|
|
@@ -391,6 +397,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
391
397
|
state.lastFullStatusRequestAt = now;
|
|
392
398
|
app.debug(`[${instanceId}] FULL_STATUS_REQUEST received — replaying values snapshot`);
|
|
393
399
|
replayValuesSnapshot("full-status-request");
|
|
400
|
+
if (fullStatusCascadeHandler) {
|
|
401
|
+
app.debug(`[${instanceId}] FULL_STATUS_REQUEST cascading to downstream clients`);
|
|
402
|
+
fullStatusCascadeHandler();
|
|
403
|
+
}
|
|
394
404
|
}
|
|
395
405
|
async function sendSourceSnapshot() {
|
|
396
406
|
if (state.stopped ||
|
|
@@ -1123,6 +1133,11 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1123
1133
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1124
1134
|
app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
|
|
1125
1135
|
});
|
|
1136
|
+
// The initial-subscribe replayValuesSnapshot fires before readyToSend
|
|
1137
|
+
// is true (pipeline not yet created) and silently returns early. Replay
|
|
1138
|
+
// now so data already in the SK tree — including values injected by a
|
|
1139
|
+
// co-located server-mode instance — is forwarded on first connect.
|
|
1140
|
+
replayValuesSnapshot("initial connect");
|
|
1126
1141
|
state.socketUdp.on("message", (msg, rinfo) => {
|
|
1127
1142
|
v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
|
|
1128
1143
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1281,10 +1296,21 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
1281
1296
|
return {
|
|
1282
1297
|
start,
|
|
1283
1298
|
stop,
|
|
1299
|
+
isServerMode: () => state.isServerMode,
|
|
1284
1300
|
getId: () => instanceId,
|
|
1285
1301
|
getName: () => state.instanceName,
|
|
1286
1302
|
getStatus: () => ({ text: state.instanceStatus, healthy: state.isHealthy }),
|
|
1287
1303
|
getState: () => state,
|
|
1288
|
-
getMetricsApi: () => metricsApi
|
|
1304
|
+
getMetricsApi: () => metricsApi,
|
|
1305
|
+
/** Register a callback to invoke when this client-mode instance handles
|
|
1306
|
+
* a FULL_STATUS_REQUEST, so the request cascades to downstream clients. */
|
|
1307
|
+
setFullStatusCascadeHandler(handler) {
|
|
1308
|
+
fullStatusCascadeHandler = handler;
|
|
1309
|
+
},
|
|
1310
|
+
/** Forward a FULL_STATUS_REQUEST to all currently-connected clients
|
|
1311
|
+
* (server-mode instances only; no-op on client-mode instances). */
|
|
1312
|
+
requestFullStatusFromAllClients() {
|
|
1313
|
+
state.pipelineServer?.requestFullStatusFromAllClients?.();
|
|
1314
|
+
}
|
|
1289
1315
|
};
|
|
1290
1316
|
}
|
|
@@ -717,6 +717,20 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
717
717
|
throw err;
|
|
718
718
|
}
|
|
719
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Send FULL_STATUS_REQUEST to every currently-connected client session.
|
|
722
|
+
* Called when this server instance itself receives a FULL_STATUS_REQUEST
|
|
723
|
+
* from an upstream server, so the request cascades down the chain:
|
|
724
|
+
* Cloud → Proxy (triggers this) → Boat.
|
|
725
|
+
*/
|
|
726
|
+
function requestFullStatusFromAllClients() {
|
|
727
|
+
const secretKey = state.options?.secretKey ?? "";
|
|
728
|
+
for (const session of clientSessions.values()) {
|
|
729
|
+
_sendFullStatusRequest(session, secretKey).catch((err) => {
|
|
730
|
+
app.debug(`[v2-server] cascade FULL_STATUS_REQUEST to ${session.key} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
720
734
|
/**
|
|
721
735
|
* Build and send a META_REQUEST (0x07) control packet to a client.
|
|
722
736
|
* Instructs the client to emit a fresh metadata snapshot — used on first
|
|
@@ -1210,6 +1224,7 @@ function createPipelineV2Server(app, state, metricsApi) {
|
|
|
1210
1224
|
startACKTimer,
|
|
1211
1225
|
stopACKTimer,
|
|
1212
1226
|
startMetricsPublishing,
|
|
1213
|
-
stopMetricsPublishing
|
|
1227
|
+
stopMetricsPublishing,
|
|
1228
|
+
requestFullStatusFromAllClients
|
|
1214
1229
|
};
|
|
1215
1230
|
}
|
package/lib/values-snapshot.js
CHANGED
|
@@ -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)`
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
//
|
|
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,33 @@ function collectValuesSnapshot(app) {
|
|
|
115
171
|
}
|
|
116
172
|
const context = `${contextGroup}.${contextId}`;
|
|
117
173
|
walkValues(contextNode, [], (leaf) => {
|
|
118
|
-
|
|
174
|
+
// Values stored under "signalk-edge-link.*" $source keys were injected
|
|
175
|
+
// by this plugin (data received via an upstream edge-link server connection
|
|
176
|
+
// or a downstream edge-link client connection). Skip them only when the SK
|
|
177
|
+
// sources table cannot provide a proper original-sensor label — that case
|
|
178
|
+
// would produce wrong attribution on the receiver. When the sources table
|
|
179
|
+
// does resolve to a real label (e.g. "pypilot"), include the value so relay
|
|
180
|
+
// data reaches the upstream server after its restart; the receiver's
|
|
181
|
+
// normalizeDeltaSourceRefs will strip the stale $source and
|
|
182
|
+
// handleMessageBySource will dispatch under the original label.
|
|
183
|
+
const src = leaf.source ?? "";
|
|
184
|
+
if (src === "signalk-edge-link" ||
|
|
185
|
+
src.startsWith("signalk-edge-link.") ||
|
|
186
|
+
src.startsWith("signalk-edge-link:")) {
|
|
187
|
+
const resolved = sourceLookup.get(src);
|
|
188
|
+
const resolvedLabel = typeof resolved?.label === "string" ? resolved.label.trim() : "";
|
|
189
|
+
if (!resolvedLabel ||
|
|
190
|
+
resolvedLabel === "signalk-edge-link" ||
|
|
191
|
+
resolvedLabel.startsWith("signalk-edge-link.") ||
|
|
192
|
+
resolvedLabel.startsWith("signalk-edge-link:")) {
|
|
193
|
+
return; // No proper label available — skip to avoid wrong attribution
|
|
194
|
+
}
|
|
195
|
+
// Resolved to a real sensor label — fall through and include the value
|
|
196
|
+
}
|
|
197
|
+
const key = `${context}|${leaf.source ?? ""}|${leaf.timestamp}`;
|
|
119
198
|
const existing = grouped.get(key);
|
|
120
199
|
if (existing) {
|
|
121
200
|
existing.values.push({ path: leaf.path, value: leaf.value });
|
|
122
|
-
if (leaf.timestamp > existing.timestamp) {
|
|
123
|
-
existing.timestamp = leaf.timestamp;
|
|
124
|
-
}
|
|
125
201
|
}
|
|
126
202
|
else {
|
|
127
203
|
grouped.set(key, {
|
|
@@ -145,6 +221,10 @@ function collectValuesSnapshot(app) {
|
|
|
145
221
|
};
|
|
146
222
|
if (entry.source) {
|
|
147
223
|
update.$source = entry.source;
|
|
224
|
+
const sourceObj = resolveSource(entry.source, sourceLookup);
|
|
225
|
+
if (sourceObj) {
|
|
226
|
+
update.source = sourceObj;
|
|
227
|
+
}
|
|
148
228
|
}
|
|
149
229
|
deltas.push({ context: entry.context, updates: [update] });
|
|
150
230
|
}
|
package/package.json
CHANGED
|
@@ -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>⚠</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>⚠</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":""}
|