signalk-webhook-bridge 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to Webhook Bridge will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project uses [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [2.1.0] - 2026-09-13
9
+
10
+ ### Added
11
+
12
+ - Live **Current Payload Preview** in the plugin configuration page.
13
+ - Preview uses the current live Signal K values and shows the exact JSON structure that will be sent to the receiving webhook.
14
+ - Preview updates automatically when Signal K paths, webhook field names or output units are changed.
15
+ - Preview supports structured Signal K values such as `navigation.position`.
16
+
17
+ ### Changed
18
+
19
+ - Payload documentation in the configuration interface is now complemented by a live example using the current configuration.
20
+
21
+ ## [2.0.1] - 2026-09-13
22
+
23
+ ### Changed
24
+
25
+ - Webhook captures are now aligned to the server clock rather than being timed from when the plugin starts.
26
+ - A 60-minute interval now captures on the hour.
27
+ - A 30-minute interval captures at `:00` and `:30`.
28
+ - A 15-minute interval captures at `:00`, `:15`, `:30` and `:45`.
29
+ - The next capture time is recalculated after every snapshot to avoid gradual timer drift.
30
+ - Updated configuration help text to explain clock-aligned scheduling.
31
+
32
+ ### Added
33
+
34
+ - Automated tests for clock-aligned capture scheduling, including hourly, half-hourly, quarter-hourly, five-minute and day-boundary behaviour.
35
+
8
36
  ## [2.0.0] - 2026-09-13
9
37
 
10
38
  ### Added
package/README.md CHANGED
@@ -6,6 +6,8 @@ Choose the Signal K data paths you want to send, assign simple field names, sele
6
6
 
7
7
  If the webhook is unavailable, data is stored locally in a persistent queue and automatically delivered in order when the connection returns.
8
8
 
9
+ Captures are aligned to the Signal K server's local clock. For example, a 60-minute interval captures on the hour, while a 15-minute interval captures at :00, :15, :30 and :45.
10
+
9
11
  ## Features
10
12
 
11
13
  - Send selected Signal K data to any HTTP webhook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-webhook-bridge",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Send selected Signal K data paths to an external webhook with unit conversion, persistent queueing and automatic retry.",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -60,4 +60,4 @@
60
60
  "webpack": "^5.110.3",
61
61
  "webpack-cli": "^7.2.3"
62
62
  }
63
- }
63
+ }
package/plugin/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Signal K Webhook Bridge.
5
5
  *
6
- * Captures configured Signal K values at a selected interval,
6
+ * Captures configured Signal K values at clock-aligned intervals,
7
7
  * stores every snapshot in a persistent SQLite FIFO queue,
8
8
  * and delivers queued entries to the configured webhook in order.
9
9
  * Adds output-unit labels, retries backlog on startup and after each capture,
@@ -13,6 +13,7 @@
13
13
  const { getAvailablePaths } = require("./paths");
14
14
  const { convertValue, getOutputUnitLabel } = require("./units");
15
15
  const { sendWebhook } = require("./webhook");
16
+ const { getNextAlignedCaptureTime } = require("./schedule");
16
17
  const storage = require("./storage");
17
18
 
18
19
  module.exports = function (app) {
@@ -124,6 +125,36 @@ module.exports = function (app) {
124
125
  }
125
126
  }
126
127
 
128
+ /**
129
+ * Schedule one capture at the next clock boundary.
130
+ *
131
+ * A new timeout is calculated after every capture rather than using
132
+ * setInterval(), preventing gradual timing drift.
133
+ */
134
+ function scheduleNextCapture() {
135
+ if (stopped || !currentSettings) {
136
+ return;
137
+ }
138
+
139
+ const intervalMinutes = Number(currentSettings.sendFreq) || 10;
140
+
141
+ const nextCapture = getNextAlignedCaptureTime(intervalMinutes);
142
+
143
+ const delay = Math.max(0, nextCapture.getTime() - Date.now());
144
+
145
+ app.debug(
146
+ `Next webhook capture scheduled for ${nextCapture.toISOString()}`,
147
+ );
148
+
149
+ captureTimer = setTimeout(() => {
150
+ captureTimer = null;
151
+
152
+ captureSnapshot();
153
+
154
+ scheduleNextCapture();
155
+ }, delay);
156
+ }
157
+
127
158
  /**
128
159
  * Deliver queued entries in strict FIFO order.
129
160
  *
@@ -187,7 +218,6 @@ module.exports = function (app) {
187
218
  * This preserves FIFO order and prevents newer entries
188
219
  * overtaking an entry that has not been delivered.
189
220
  */
190
-
191
221
  lastError = error.message;
192
222
  deliveryState = "waiting";
193
223
 
@@ -213,17 +243,22 @@ module.exports = function (app) {
213
243
  }
214
244
  }
215
245
 
216
-
217
- // Complete deferred cleanup after any in-flight request has settled.
246
+ /*
247
+ * Complete deferred cleanup after any in-flight request
248
+ * has settled.
249
+ */
218
250
  if (stopped) {
219
251
  storage.close();
220
252
  }
221
253
  }
222
254
  }
223
255
 
224
- // Open storage, attempt backlog delivery and schedule the first capture after one interval.
256
+ /**
257
+ * Open storage, attempt backlog delivery and schedule the first
258
+ * clock-aligned capture.
259
+ */
225
260
  plugin.start = function (settings) {
226
- app.debug("Starting Signal K Webhook Bridge");
261
+ app.debug("Starting Webhook Bridge");
227
262
 
228
263
  currentSettings = settings || {};
229
264
  stopped = false;
@@ -248,34 +283,36 @@ module.exports = function (app) {
248
283
  * sendFreq is configured in minutes.
249
284
  */
250
285
  const sendFreq = Number(currentSettings.sendFreq) || 10;
251
- const intervalMilliseconds = sendFreq * 60 * 1000;
252
286
 
253
287
  app.debug(
254
288
  `Webhook capture interval set to ${sendFreq} minute${
255
289
  sendFreq === 1 ? "" : "s"
256
- }`,
290
+ }, aligned to the clock`,
257
291
  );
258
292
 
259
293
  /*
260
294
  * Immediately attempt to clear any backlog left from a previous
261
295
  * offline period or server restart.
262
296
  *
263
- * We do NOT capture a new snapshot immediately. New snapshots
264
- * follow the configured interval.
297
+ * We do NOT capture a new snapshot immediately. The first new
298
+ * snapshot is taken at the next clock-aligned boundary.
265
299
  */
266
300
  processQueue();
267
301
 
268
- captureTimer = setInterval(captureSnapshot, intervalMilliseconds);
302
+ scheduleNextCapture();
269
303
  };
270
304
 
271
- // Stop future captures; an in-flight delivery finishes before its worker closes storage.
305
+ /**
306
+ * Stop future captures; an in-flight delivery finishes before its
307
+ * worker closes storage.
308
+ */
272
309
  plugin.stop = function () {
273
- app.debug("Stopping Signal K Webhook Bridge");
310
+ app.debug("Stopping Webhook Bridge");
274
311
 
275
312
  stopped = true;
276
313
 
277
314
  if (captureTimer) {
278
- clearInterval(captureTimer);
315
+ clearTimeout(captureTimer);
279
316
  captureTimer = null;
280
317
  }
281
318
 
@@ -325,6 +362,7 @@ module.exports = function (app) {
325
362
  });
326
363
  }
327
364
  });
365
+
328
366
  router.get("/status", (req, res) => {
329
367
  try {
330
368
  const queueCount = storage.count();
@@ -368,11 +406,37 @@ module.exports = function (app) {
368
406
  });
369
407
  }
370
408
  });
409
+
410
+ router.post("/preview", (req, res) => {
411
+ try {
412
+ const previewSettings = {
413
+ paths: Array.isArray(req.body?.paths) ? req.body.paths : [],
414
+ };
415
+
416
+ const snapshot = buildSnapshot(previewSettings);
417
+
418
+ res.status(200).json({
419
+ timestamp: new Date().toISOString(),
420
+ ...snapshot,
421
+ });
422
+ } catch (error) {
423
+ app.error(
424
+ `Unable to generate webhook preview: ${error.message}`,
425
+ );
426
+
427
+ res.status(500).json({
428
+ error: "Unable to generate webhook preview",
429
+ });
430
+ }
431
+ });
371
432
  };
372
433
 
373
434
  /**
374
- * Describe webhook settings and path mappings for the schema-based configuration form.
375
- * Path choices are discovered when requested; the custom panel filters units by metadata.
435
+ * Describe webhook settings and path mappings for the schema-based
436
+ * configuration form.
437
+ *
438
+ * Path choices are discovered when requested; the custom panel
439
+ * filters units by metadata.
376
440
  */
377
441
  plugin.schema = function () {
378
442
  const availablePaths = getAvailablePaths(app);
@@ -405,7 +469,7 @@ module.exports = function (app) {
405
469
  type: "number",
406
470
  title: "Send Interval",
407
471
  description:
408
- "How often data should be captured and sent, in minutes.",
472
+ "Capture interval in minutes, aligned to the server clock. For example, 60 sends on the hour and 15 sends at :00, :15, :30 and :45.",
409
473
  default: 10,
410
474
  minimum: 1,
411
475
  },
@@ -0,0 +1,21 @@
1
+ function getNextAlignedCaptureTime(intervalMinutes, now = new Date()) {
2
+ const interval = Number(intervalMinutes);
3
+
4
+ if (!Number.isFinite(interval) || interval <= 0) {
5
+ throw new Error("Capture interval must be greater than zero");
6
+ }
7
+
8
+ const startOfDay = new Date(now);
9
+ startOfDay.setHours(0, 0, 0, 0);
10
+
11
+ const intervalMs = interval * 60 * 1000;
12
+ const elapsedToday = now.getTime() - startOfDay.getTime();
13
+
14
+ const nextElapsed = Math.floor(elapsedToday / intervalMs + 1) * intervalMs;
15
+
16
+ return new Date(startOfDay.getTime() + nextElapsed);
17
+ }
18
+
19
+ module.exports = {
20
+ getNextAlignedCaptureTime,
21
+ };
@@ -1,2 +1,2 @@
1
1
  /*! For license information please see 651.main.js.LICENSE.txt */
2
- "use strict";(self.webpackChunksignalk_webhook_bridge=self.webpackChunksignalk_webhook_bridge||[]).push([[651],{651(e,t,a){a.r(t),a.d(t,{default:()=>s});var r=a.cw(function(e,t){var a=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function i(e,t,r){var i=null;if(void 0!==r&&(i=""+r),void 0!==t.key&&(i=""+t.key),"key"in t)for(var n in r={},t)"key"!==n&&(r[n]=t[n]);else r=t;return t=r.ref,{$$typeof:a,type:e,key:i,ref:void 0!==t?t:null,props:r}}t.Fragment=r,t.jsx=i,t.jsxs=i}),i=a.cw(function(e,t){e.exports=r()}),n=a(231);function s({configuration:e={},save:t}){const[a,i]=(0,n.useState)(e.webhookUrl||""),[s,l]=(0,n.useState)(e.authKey||""),[o,d]=(0,n.useState)(e.sendFreq||10),[h,u]=(0,n.useState)(Array.isArray(e.paths)?e.paths:[]),[c,g]=(0,n.useState)([]),[m,p]=(0,n.useState)(!0),[y,v]=(0,n.useState)(""),[b,x]=(0,n.useState)(null),[j,f]=(0,n.useState)(null),[k,w]=(0,n.useState)(""),[S,C]=(0,n.useState)(!1);(0,n.useEffect)(()=>{fetch("/plugins/signalk-webhook-bridge/paths").then(e=>{if(!e.ok)throw new Error(`HTTP ${e.status}`);return e.json()}).then(e=>{g(Array.isArray(e.paths)?e.paths:[]),p(!1)}).catch(e=>{console.error("Unable to load Signal K paths:",e),v("Unable to load available Signal K paths."),p(!1)})},[]),(0,n.useEffect)(()=>{let e=!0;const t=async()=>{try{const t=await fetch("/plugins/signalk-webhook-bridge/status");if(!t.ok)throw new Error(`HTTP ${t.status}`);const a=await t.json();e&&(f(a),w(""))}catch(t){console.error("Unable to load webhook status:",t),e&&w("Unable to load webhook status.")}};t();const a=setInterval(t,5e3);return()=>{e=!1,clearInterval(a)}},[]);const B=(e,t,a)=>{u(r=>r.map((r,i)=>i===e?{...r,[t]:a}:r))};return(0,r().jsxs)("div",{style:{padding:"1rem",maxWidth:"900px"},children:[(0,r().jsx)("h2",{children:"Webhook Bridge"}),(0,r().jsx)("div",{style:{marginBottom:"1.25rem",maxWidth:"750px",lineHeight:"1.5",opacity:.8},children:"Send selected Signal K data to an external webhook at a regular interval. Choose the data paths to include, give each value a webhook field name, and select the units you want to send. If delivery is unavailable, updates are stored locally and automatically sent in order when the connection returns."}),(0,r().jsxs)("div",{style:{marginBottom:"1.5rem",padding:"1rem",borderRadius:"6px",transition:"background 0.2s ease, border 0.2s ease",...(e=>{switch(e){case"queued":case"waiting":return{background:"rgba(255, 152, 0, 0.10)",border:"1px solid rgba(255, 152, 0, 0.40)"};case"error":return{background:"rgba(244, 67, 54, 0.10)",border:"1px solid rgba(244, 67, 54, 0.40)"};default:return{background:"rgba(33, 150, 243, 0.08)",border:"1px solid rgba(33, 150, 243, 0.35)"}}})(j?.deliveryState)},children:[(0,r().jsxs)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:"1rem",marginBottom:"0.75rem"},children:[(0,r().jsxs)("div",{children:[(0,r().jsx)("h3",{style:{margin:0},children:"Status"}),(0,r().jsx)("div",{style:{marginTop:"0.25rem",fontSize:"0.9rem",opacity:.75},children:"Current delivery and queue status."})]}),(0,r().jsx)("button",{type:"button",onClick:async()=>{C(!0);try{const e=await fetch("/plugins/signalk-webhook-bridge/retry",{method:"POST"});if(!e.ok)throw new Error(`HTTP ${e.status}`);const t=await e.json();f(t),w("")}catch(e){console.error("Unable to retry webhook delivery:",e),w("Unable to retry webhook delivery.")}finally{C(!1)}},disabled:S,style:{padding:"0.5rem 0.8rem",cursor:S?"default":"pointer",opacity:S?.7:1},children:S?"Retrying...":"Retry Now"})]}),k&&(0,r().jsx)("div",{style:{marginBottom:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(244, 67, 54, 0.45)",borderRadius:"6px",background:"rgba(244, 67, 54, 0.12)"},children:k}),!j&&!k&&(0,r().jsx)("div",{style:{opacity:.75},children:"Loading status..."}),j&&(0,r().jsxs)("div",{style:{display:"grid",gridTemplateColumns:"180px 1fr",gap:"0.5rem 1rem"},children:[(0,r().jsx)("div",{style:{fontWeight:"600"},children:"Delivery"}),(0,r().jsx)("div",{children:"connected"===j.deliveryState?"Connected":"delivering"===j.deliveryState?"Delivering":"waiting"===j.deliveryState?"Waiting to retry":"queued"===j.deliveryState?"Queued":"error"===j.deliveryState?"Error":"Idle"}),(0,r().jsx)("div",{style:{fontWeight:"600"},children:"Queue"}),(0,r().jsxs)("div",{children:[j.queueCount," ",1===j.queueCount?"entry":"entries"," ","waiting"]}),(0,r().jsx)("div",{style:{fontWeight:"600"},children:"Last capture"}),(0,r().jsx)("div",{children:j.lastCapture?new Date(j.lastCapture).toLocaleString():"Not yet"}),(0,r().jsx)("div",{style:{fontWeight:"600"},children:"Last delivery"}),(0,r().jsx)("div",{children:j.lastDelivery?new Date(j.lastDelivery).toLocaleString():"Not yet"}),j.lastError&&(0,r().jsxs)(r().Fragment,{children:[(0,r().jsx)("div",{style:{fontWeight:"600"},children:"Last error"}),(0,r().jsx)("div",{style:{wordBreak:"break-word"},children:j.lastError})]})]})]}),(0,r().jsxs)("div",{style:{marginBottom:"1rem"},children:[(0,r().jsx)("label",{htmlFor:"webhookUrl",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Webhook URL"}),(0,r().jsx)("input",{id:"webhookUrl",type:"url",value:a,onChange:e=>i(e.target.value),placeholder:"https://example.com/webhook",style:{width:"100%",padding:"0.5rem"}})]}),(0,r().jsxs)("div",{style:{marginBottom:"1rem"},children:[(0,r().jsx)("label",{htmlFor:"authKey",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Authentication Key"}),(0,r().jsx)("input",{id:"authKey",type:"text",value:s,onChange:e=>l(e.target.value),placeholder:"Optional",style:{width:"100%",padding:"0.5rem"}})]}),(0,r().jsxs)("div",{style:{marginBottom:"1.5rem"},children:[(0,r().jsx)("label",{htmlFor:"sendFreq",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Send Interval"}),(0,r().jsx)("input",{id:"sendFreq",type:"number",min:"1",value:o,onChange:e=>d(e.target.value),style:{width:"150px",padding:"0.5rem"}}),(0,r().jsx)("div",{style:{marginTop:"0.35rem",fontSize:"0.9rem",opacity:.75},children:"Minutes between webhook updates."})]}),(0,r().jsx)("hr",{style:{margin:"1.5rem 0"}}),(0,r().jsx)("div",{style:{marginBottom:"1rem"},children:(0,r().jsxs)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:"1rem"},children:[(0,r().jsxs)("div",{children:[(0,r().jsx)("h3",{style:{marginBottom:"0.25rem"},children:"Signal K Data Paths"}),(0,r().jsx)("div",{style:{fontSize:"0.9rem",opacity:.75},children:"Choose the Signal K values to include in each webhook update."})]}),(0,r().jsx)("button",{type:"button",onClick:()=>{u(e=>[...e,{path:"",fieldName:"",units:"native"}])},style:{padding:"0.5rem 0.8rem",cursor:"pointer"},children:"Add Path"})]})}),0===h.length&&(0,r().jsx)("div",{style:{padding:"1rem",border:"1px solid rgba(128,128,128,0.35)",borderRadius:"6px",marginBottom:"1rem",opacity:.8},children:"No Signal K paths added yet."}),h.map((e,t)=>{const a=c.find(t=>t.path===e.path),i=function(e){switch(e){case"m/s":return[{value:"native",label:"Native (m/s)"},{value:"knots",label:"Knots"},{value:"kmh",label:"km/h"},{value:"mph",label:"mph"}];case"rad":return[{value:"native",label:"Native (rad)"},{value:"degrees",label:"Degrees"}];case"m":return[{value:"native",label:"Native (m)"},{value:"metres",label:"Metres"},{value:"feet",label:"Feet"}];case"K":return[{value:"native",label:"Native (K)"},{value:"celsius",label:"Celsius"},{value:"fahrenheit",label:"Fahrenheit"}];case"Pa":return[{value:"native",label:"Native (Pa)"},{value:"hpa",label:"hPa"},{value:"mbar",label:"mbar"}];default:return[{value:"native",label:e?`Native (${e})`:"Native"}]}}(a?.units);return(0,r().jsx)("div",{style:{border:"1px solid rgba(128,128,128,0.35)",borderRadius:"6px",padding:"1rem",marginBottom:"1rem"},children:(0,r().jsxs)("div",{style:{display:"grid",gridTemplateColumns:"minmax(220px, 2fr) minmax(180px, 1.2fr) minmax(140px, 1fr) auto",gap:"0.75rem",alignItems:"end"},children:[(0,r().jsxs)("div",{children:[(0,r().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Signal K Path"}),(0,r().jsx)("input",{type:"text",list:`signalk-paths-${t}`,value:e.path,onChange:e=>B(t,"path",e.target.value),placeholder:m?"Loading Signal K paths...":"Search Signal K paths...",disabled:m,style:{width:"100%",padding:"0.5rem"}}),(0,r().jsx)("datalist",{id:`signalk-paths-${t}`,children:c.map(e=>(0,r().jsx)("option",{value:e.path},e.path))}),y&&(0,r().jsx)("div",{style:{marginTop:"0.35rem",fontSize:"0.85rem"},children:y})]}),(0,r().jsxs)("div",{children:[(0,r().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Webhook Field Name"}),(0,r().jsx)("input",{type:"text",value:e.fieldName,onChange:e=>B(t,"fieldName",e.target.value),placeholder:"speed",style:{width:"100%",padding:"0.5rem"}})]}),(0,r().jsxs)("div",{children:[(0,r().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Output Units"}),(0,r().jsx)("select",{value:i.some(t=>t.value===e.units)?e.units:"native",onChange:e=>B(t,"units",e.target.value),style:{width:"100%",padding:"0.5rem"},children:i.map(e=>(0,r().jsx)("option",{value:e.value,children:e.label},e.value))})]}),(0,r().jsx)("button",{type:"button",onClick:()=>{return e=t,void u(t=>t.filter((t,a)=>a!==e));var e},style:{padding:"0.5rem 0.75rem",cursor:"pointer"},children:"Remove"})]})},t)}),(0,r().jsxs)("details",{style:{marginTop:"1.5rem",marginBottom:"1.5rem"},children:[(0,r().jsx)("summary",{style:{cursor:"pointer",fontWeight:"600"},children:"What does Webhook Bridge send?"}),(0,r().jsxs)("div",{style:{marginTop:"1rem",lineHeight:"1.5"},children:[(0,r().jsxs)("p",{children:["Webhook Bridge sends an HTTP POST request containing a JSON object. Each configured Signal K path is sent using the ",(0,r().jsx)("strong",{children:"Webhook Field Name"})," you specify above, with values converted to your selected output units."]}),(0,r().jsxs)("p",{children:["Each request also contains the time the update was captured and a ",(0,r().jsx)("code",{children:"units"})," object describing the units used."]}),(0,r().jsx)("pre",{style:{padding:"1rem",overflowX:"auto",borderRadius:"4px",background:"rgba(128,128,128,0.10)"},children:'{\n "timestamp": "2026-09-13T10:15:00.000Z",\n "speed": 5.64,\n "depth": 12.8,\n "units": {\n "speed": "kn",\n "depth": "m"\n }\n}'}),(0,r().jsxs)("p",{children:["Your webhook should return an HTTP"," ",(0,r().jsx)("strong",{children:"2xx response"})," when the data has been successfully received. If delivery fails, the update remains in the local queue and will be sent again later in its original order."]}),(0,r().jsxs)("p",{style:{marginBottom:0},children:["If an Authentication Key is configured, it is sent as a"," ",(0,r().jsx)("strong",{children:"Bearer token"})," in the"," ",(0,r().jsx)("code",{children:"Authorization"})," header."]})]})]}),(0,r().jsx)("button",{type:"button",onClick:async()=>{x("saving");try{await t({...e,webhookUrl:a,authKey:s,sendFreq:Number(o),paths:h}),x("saved"),setTimeout(()=>{x(null)},4e3)}catch(e){console.error("Unable to save configuration:",e),x("error")}},disabled:"saving"===b,style:{padding:"0.55rem 1rem",cursor:"saving"===b?"default":"pointer",marginTop:"0.5rem",opacity:"saving"===b?.7:1},children:"saving"===b?"Saving...":"Save Configuration"}),"saved"===b&&(0,r().jsx)("div",{style:{marginTop:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(76, 175, 80, 0.45)",borderRadius:"6px",background:"rgba(76, 175, 80, 0.12)"},children:"Configuration saved successfully."}),"error"===b&&(0,r().jsx)("div",{style:{marginTop:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(244, 67, 54, 0.45)",borderRadius:"6px",background:"rgba(244, 67, 54, 0.12)"},children:"Unable to save configuration. Please try again."})]})}i()}}]);
2
+ "use strict";(self.webpackChunksignalk_webhook_bridge=self.webpackChunksignalk_webhook_bridge||[]).push([[651],{651(e,t,r){r.r(t),r.d(t,{default:()=>l});var a=r.cw(function(e,t){var r=Symbol.for("react.transitional.element"),a=Symbol.for("react.fragment");function i(e,t,a){var i=null;if(void 0!==a&&(i=""+a),void 0!==t.key&&(i=""+t.key),"key"in t)for(var n in a={},t)"key"!==n&&(a[n]=t[n]);else a=t;return t=a.ref,{$$typeof:r,type:e,key:i,ref:void 0!==t?t:null,props:a}}t.Fragment=a,t.jsx=i,t.jsxs=i}),i=r.cw(function(e,t){e.exports=a()}),n=r(231);function l({configuration:e={},save:t}){const[r,i]=(0,n.useState)(e.webhookUrl||""),[l,s]=(0,n.useState)(e.authKey||""),[o,d]=(0,n.useState)(e.sendFreq||10),[u,h]=(0,n.useState)(Array.isArray(e.paths)?e.paths:[]),[c,g]=(0,n.useState)([]),[m,p]=(0,n.useState)(!0),[v,y]=(0,n.useState)(""),[b,x]=(0,n.useState)(null),[f,j]=(0,n.useState)(null),[k,w]=(0,n.useState)(""),[S,C]=(0,n.useState)(!1),[T,B]=(0,n.useState)(null),[K,N]=(0,n.useState)(!1),[W,U]=(0,n.useState)("");(0,n.useEffect)(()=>{fetch("/plugins/signalk-webhook-bridge/paths").then(e=>{if(!e.ok)throw new Error(`HTTP ${e.status}`);return e.json()}).then(e=>{g(Array.isArray(e.paths)?e.paths:[]),p(!1)}).catch(e=>{console.error("Unable to load Signal K paths:",e),y("Unable to load available Signal K paths."),p(!1)})},[]),(0,n.useEffect)(()=>{let e=!0;const t=async()=>{try{const t=await fetch("/plugins/signalk-webhook-bridge/status");if(!t.ok)throw new Error(`HTTP ${t.status}`);const r=await t.json();e&&(j(r),w(""))}catch(t){console.error("Unable to load webhook status:",t),e&&w("Unable to load webhook status.")}};t();const r=setInterval(t,5e3);return()=>{e=!1,clearInterval(r)}},[]),(0,n.useEffect)(()=>{const e=u.filter(e=>e&&e.path);if(0===e.length)return B(null),U(""),void N(!1);const t=setTimeout(()=>{N(!0),U(""),fetch("/plugins/signalk-webhook-bridge/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({paths:e})}).then(async e=>{if(!e.ok){const t=await e.text();throw new Error(t||`HTTP ${e.status}`)}return e.json()}).then(e=>{B(e),N(!1)}).catch(e=>{console.error("Unable to generate webhook preview:",e),U("Unable to generate the payload preview."),N(!1)})},350);return()=>clearTimeout(t)},[u]);const P=(e,t,r)=>{h(a=>a.map((a,i)=>i===e?{...a,[t]:r}:a))};return(0,a().jsxs)("div",{style:{padding:"1rem",maxWidth:"900px"},children:[(0,a().jsx)("h2",{children:"Webhook Bridge"}),(0,a().jsx)("div",{style:{marginBottom:"1.25rem",maxWidth:"750px",lineHeight:"1.5",opacity:.8},children:"Send selected Signal K data to an external webhook at a regular interval. Choose the data paths to include, give each value a webhook field name, and select the units you want to send. If delivery is unavailable, updates are stored locally and automatically sent in order when the connection returns."}),(0,a().jsxs)("div",{style:{marginBottom:"1.5rem",padding:"1rem",borderRadius:"6px",transition:"background 0.2s ease, border 0.2s ease",...(e=>{switch(e){case"queued":case"waiting":return{background:"rgba(255, 152, 0, 0.10)",border:"1px solid rgba(255, 152, 0, 0.40)"};case"error":return{background:"rgba(244, 67, 54, 0.10)",border:"1px solid rgba(244, 67, 54, 0.40)"};default:return{background:"rgba(33, 150, 243, 0.08)",border:"1px solid rgba(33, 150, 243, 0.35)"}}})(f?.deliveryState)},children:[(0,a().jsxs)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:"1rem",marginBottom:"0.75rem"},children:[(0,a().jsxs)("div",{children:[(0,a().jsx)("h3",{style:{margin:0},children:"Status"}),(0,a().jsx)("div",{style:{marginTop:"0.25rem",fontSize:"0.9rem",opacity:.75},children:"Current delivery and queue status."})]}),(0,a().jsx)("button",{type:"button",onClick:async()=>{C(!0);try{const e=await fetch("/plugins/signalk-webhook-bridge/retry",{method:"POST"});if(!e.ok)throw new Error(`HTTP ${e.status}`);const t=await e.json();j(t),w("")}catch(e){console.error("Unable to retry webhook delivery:",e),w("Unable to retry webhook delivery.")}finally{C(!1)}},disabled:S,style:{padding:"0.5rem 0.8rem",cursor:S?"default":"pointer",opacity:S?.7:1},children:S?"Retrying...":"Retry Now"})]}),k&&(0,a().jsx)("div",{style:{marginBottom:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(244, 67, 54, 0.45)",borderRadius:"6px",background:"rgba(244, 67, 54, 0.12)"},children:k}),!f&&!k&&(0,a().jsx)("div",{style:{opacity:.75},children:"Loading status..."}),f&&(0,a().jsxs)("div",{style:{display:"grid",gridTemplateColumns:"180px 1fr",gap:"0.5rem 1rem"},children:[(0,a().jsx)("div",{style:{fontWeight:"600"},children:"Delivery"}),(0,a().jsx)("div",{children:"connected"===f.deliveryState?"Connected":"delivering"===f.deliveryState?"Delivering":"waiting"===f.deliveryState?"Waiting to retry":"queued"===f.deliveryState?"Queued":"error"===f.deliveryState?"Error":"Idle"}),(0,a().jsx)("div",{style:{fontWeight:"600"},children:"Queue"}),(0,a().jsxs)("div",{children:[f.queueCount," ",1===f.queueCount?"entry":"entries"," ","waiting"]}),(0,a().jsx)("div",{style:{fontWeight:"600"},children:"Last capture"}),(0,a().jsx)("div",{children:f.lastCapture?new Date(f.lastCapture).toLocaleString():"Not yet"}),(0,a().jsx)("div",{style:{fontWeight:"600"},children:"Last delivery"}),(0,a().jsx)("div",{children:f.lastDelivery?new Date(f.lastDelivery).toLocaleString():"Not yet"}),f.lastError&&(0,a().jsxs)(a().Fragment,{children:[(0,a().jsx)("div",{style:{fontWeight:"600"},children:"Last error"}),(0,a().jsx)("div",{style:{wordBreak:"break-word"},children:f.lastError})]})]})]}),(0,a().jsxs)("div",{style:{marginBottom:"1rem"},children:[(0,a().jsx)("label",{htmlFor:"webhookUrl",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Webhook URL"}),(0,a().jsx)("input",{id:"webhookUrl",type:"url",value:r,onChange:e=>i(e.target.value),placeholder:"https://example.com/webhook",style:{width:"100%",padding:"0.5rem"}})]}),(0,a().jsxs)("div",{style:{marginBottom:"1rem"},children:[(0,a().jsx)("label",{htmlFor:"authKey",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Authentication Key"}),(0,a().jsx)("input",{id:"authKey",type:"text",value:l,onChange:e=>s(e.target.value),placeholder:"Optional",style:{width:"100%",padding:"0.5rem"}})]}),(0,a().jsxs)("div",{style:{marginBottom:"1.5rem"},children:[(0,a().jsx)("label",{htmlFor:"sendFreq",style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Send Interval"}),(0,a().jsx)("input",{id:"sendFreq",type:"number",min:"1",value:o,onChange:e=>d(e.target.value),style:{width:"150px",padding:"0.5rem"}}),(0,a().jsx)("div",{style:{marginTop:"0.35rem",fontSize:"0.9rem",opacity:.75},children:"Minutes between webhook updates."})]}),(0,a().jsx)("hr",{style:{margin:"1.5rem 0"}}),(0,a().jsx)("div",{style:{marginBottom:"1rem"},children:(0,a().jsxs)("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:"1rem"},children:[(0,a().jsxs)("div",{children:[(0,a().jsx)("h3",{style:{marginBottom:"0.25rem"},children:"Signal K Data Paths"}),(0,a().jsx)("div",{style:{fontSize:"0.9rem",opacity:.75},children:"Choose the Signal K values to include in each webhook update."})]}),(0,a().jsx)("button",{type:"button",onClick:()=>{h(e=>[...e,{path:"",fieldName:"",units:"native"}])},style:{padding:"0.5rem 0.8rem",cursor:"pointer"},children:"Add Path"})]})}),0===u.length&&(0,a().jsx)("div",{style:{padding:"1rem",border:"1px solid rgba(128,128,128,0.35)",borderRadius:"6px",marginBottom:"1rem",opacity:.8},children:"No Signal K paths added yet."}),u.map((e,t)=>{const r=c.find(t=>t.path===e.path),i=function(e){switch(e){case"m/s":return[{value:"native",label:"Native (m/s)"},{value:"knots",label:"Knots"},{value:"kmh",label:"km/h"},{value:"mph",label:"mph"}];case"rad":return[{value:"native",label:"Native (rad)"},{value:"degrees",label:"Degrees"}];case"m":return[{value:"native",label:"Native (m)"},{value:"metres",label:"Metres"},{value:"feet",label:"Feet"}];case"K":return[{value:"native",label:"Native (K)"},{value:"celsius",label:"Celsius"},{value:"fahrenheit",label:"Fahrenheit"}];case"Pa":return[{value:"native",label:"Native (Pa)"},{value:"hpa",label:"hPa"},{value:"mbar",label:"mbar"}];default:return[{value:"native",label:e?`Native (${e})`:"Native"}]}}(r?.units);return(0,a().jsx)("div",{style:{border:"1px solid rgba(128,128,128,0.35)",borderRadius:"6px",padding:"1rem",marginBottom:"1rem"},children:(0,a().jsxs)("div",{style:{display:"grid",gridTemplateColumns:"minmax(220px, 2fr) minmax(180px, 1.2fr) minmax(140px, 1fr) auto",gap:"0.75rem",alignItems:"end"},children:[(0,a().jsxs)("div",{children:[(0,a().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Signal K Path"}),(0,a().jsx)("input",{type:"text",list:`signalk-paths-${t}`,value:e.path,onChange:e=>P(t,"path",e.target.value),placeholder:m?"Loading Signal K paths...":"Search Signal K paths...",disabled:m,style:{width:"100%",padding:"0.5rem"}}),(0,a().jsx)("datalist",{id:`signalk-paths-${t}`,children:c.map(e=>(0,a().jsx)("option",{value:e.path},e.path))}),v&&(0,a().jsx)("div",{style:{marginTop:"0.35rem",fontSize:"0.85rem"},children:v})]}),(0,a().jsxs)("div",{children:[(0,a().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Webhook Field Name"}),(0,a().jsx)("input",{type:"text",value:e.fieldName,onChange:e=>P(t,"fieldName",e.target.value),placeholder:"speed",style:{width:"100%",padding:"0.5rem"}})]}),(0,a().jsxs)("div",{children:[(0,a().jsx)("label",{style:{display:"block",fontWeight:"600",marginBottom:"0.35rem"},children:"Output Units"}),(0,a().jsx)("select",{value:i.some(t=>t.value===e.units)?e.units:"native",onChange:e=>P(t,"units",e.target.value),style:{width:"100%",padding:"0.5rem"},children:i.map(e=>(0,a().jsx)("option",{value:e.value,children:e.label},e.value))})]}),(0,a().jsx)("button",{type:"button",onClick:()=>{return e=t,void h(t=>t.filter((t,r)=>r!==e));var e},style:{padding:"0.5rem 0.75rem",cursor:"pointer"},children:"Remove"})]})},t)}),(0,a().jsxs)("div",{style:{marginTop:"1.5rem",marginBottom:"1.5rem",border:"1px solid rgba(128,128,128,0.35)",borderRadius:"6px",overflow:"hidden"},children:[(0,a().jsxs)("div",{style:{padding:"0.9rem 1rem",borderBottom:"1px solid rgba(128,128,128,0.25)"},children:[(0,a().jsx)("div",{style:{fontWeight:"600",fontSize:"1.05rem"},children:"Current Payload Preview"}),(0,a().jsx)("div",{style:{marginTop:"0.25rem",fontSize:"0.9rem",opacity:.75},children:"This is the JSON structure your webhook will receive using the paths, field names and units currently configured above. Values shown are the current live Signal K values."})]}),(0,a().jsxs)("div",{style:{padding:"1rem"},children:[0===u.filter(e=>e&&e.path).length&&(0,a().jsx)("div",{style:{opacity:.7},children:"Add a Signal K path to see the outgoing payload."}),K&&!T&&(0,a().jsx)("div",{style:{opacity:.7},children:"Loading current Signal K values..."}),W&&(0,a().jsx)("div",{style:{padding:"0.75rem",borderRadius:"4px",background:"rgba(244, 67, 54, 0.10)",border:"1px solid rgba(244, 67, 54, 0.35)"},children:W}),T&&(0,a().jsxs)(a().Fragment,{children:[(0,a().jsx)("pre",{style:{margin:0,padding:"1rem",overflowX:"auto",borderRadius:"4px",background:"rgba(128,128,128,0.10)",fontSize:"0.9rem",lineHeight:"1.5"},children:JSON.stringify(T,null,2)}),K&&(0,a().jsx)("div",{style:{marginTop:"0.5rem",fontSize:"0.85rem",opacity:.6},children:"Updating preview..."})]})]})]}),(0,a().jsx)("button",{type:"button",onClick:async()=>{x("saving");try{await t({...e,webhookUrl:r,authKey:l,sendFreq:Number(o),paths:u}),x("saved"),setTimeout(()=>{x(null)},4e3)}catch(e){console.error("Unable to save configuration:",e),x("error")}},disabled:"saving"===b,style:{padding:"0.55rem 1rem",cursor:"saving"===b?"default":"pointer",marginTop:"0.5rem",opacity:"saving"===b?.7:1},children:"saving"===b?"Saving...":"Save Configuration"}),"saved"===b&&(0,a().jsx)("div",{style:{marginTop:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(76, 175, 80, 0.45)",borderRadius:"6px",background:"rgba(76, 175, 80, 0.12)"},children:"Configuration saved successfully."}),"error"===b&&(0,a().jsx)("div",{style:{marginTop:"0.75rem",padding:"0.65rem 0.8rem",border:"1px solid rgba(244, 67, 54, 0.45)",borderRadius:"6px",background:"rgba(244, 67, 54, 0.12)"},children:"Unable to save configuration. Please try again."})]})}i()}}]);