signalk-chiplog 2.3.0 → 2.3.1

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
@@ -6,6 +6,27 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.3.1] - 2026-09-18
10
+
11
+ ### Added
12
+
13
+ - **Retrospective analysis now shows what has been reconstructed so far while it is still running**, not only once it
14
+ finishes: passages, distance, engine/sail time, track points and events update after every committed slice instead of
15
+ only the clock position. A run that fails partway — an InfluxDB query timing out on a slow host — keeps that same
16
+ summary next to the error, instead of leaving a bare error message with no way to tell what was saved.
17
+ - **A retrospective replay retries an InfluxDB query that times out** up to 3 times, 5 seconds apart, instead of failing
18
+ the whole run on what is often just a Raspberry Pi momentarily busy sharing its InfluxDB with Signal K itself; the
19
+ webapp shows which attempt is under way while it waits. The timeout covers the whole round trip, including a chunk's
20
+ JSON still streaming in after the connection answered, not just getting a connection in the first place. Each window
21
+ of history is also fetched in smaller, two-hour chunks instead of six, so a slow host has less to answer per request.
22
+
23
+ ### Fixed
24
+
25
+ - **A retrospective replay running alongside live tracking** could have a live detection tick, track sample or event
26
+ check land on the passage the replay was reconstructing — mistaking it for the current one, since both read the same
27
+ `active` row — and close it early or splice live position and instrument data into a past passage. Live detection,
28
+ track sampling and event watching now pause for as long as a replay is running.
29
+
9
30
  ## [2.3.0] - 2026-09-18
10
31
 
11
32
  ### Added
@@ -266,7 +287,8 @@ First release.
266
287
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
267
288
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
268
289
 
269
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...HEAD
290
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...HEAD
291
+ [2.3.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...v2.3.1
270
292
  [2.3.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...v2.3.0
271
293
  [2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
272
294
  [2.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.0.0...v2.1.0
package/index.js CHANGED
@@ -274,6 +274,20 @@ module.exports = function (app) {
274
274
  };
275
275
 
276
276
  function runDetection() {
277
+ // A retrospective replay drives the same detector against a past window,
278
+ // holding its own passage 'active' in log_entries for as long as it takes
279
+ // to close it there — live detection, track sampling and event watching
280
+ // must not touch that row in the meantime, or a live tick would treat a
281
+ // reconstructed passage as the current one and close it early or splice
282
+ // live data into it.
283
+ if (replayJob?.status().running) {
284
+ const status = 'Retrospective replay in progress — live tracking paused';
285
+ if (status !== lastStatus) {
286
+ app.setPluginStatus(status);
287
+ lastStatus = status;
288
+ }
289
+ return;
290
+ }
277
291
  try {
278
292
  const outcome = detector.tick();
279
293
  usbExport.afterDetection(outcome);
@@ -471,11 +485,19 @@ module.exports = function (app) {
471
485
  timers = [
472
486
  setInterval(runDetection, TICK_INTERVAL_MS),
473
487
  setInterval(
474
- guarded('Track recording', () => recorder.sample()),
488
+ guarded('Track recording', () => {
489
+ if (!replayJob?.status().running) {
490
+ recorder.sample();
491
+ }
492
+ }),
475
493
  SAMPLE_INTERVAL_MS
476
494
  ),
477
495
  setInterval(
478
- guarded('Event watching', () => watcher.check()),
496
+ guarded('Event watching', () => {
497
+ if (!replayJob?.status().running) {
498
+ watcher.check();
499
+ }
500
+ }),
479
501
  CHECK_INTERVAL_MS
480
502
  ),
481
503
  setInterval(
@@ -18,13 +18,19 @@ const DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
18
18
 
19
19
  // A resource-constrained host (a Raspberry Pi running both Signal K and
20
20
  // InfluxDB) can be brought down by one query spanning weeks across every
21
- // path at once; six hours at a time keeps each request's result small
21
+ // path at once; two hours at a time keeps each request's result small
22
22
  // regardless of how long the requested range is, with a short pause between
23
23
  // them so the database is never asked for the next one while still
24
24
  // recovering from the last.
25
- const CHUNK_MS = 6 * 60 * 60 * 1000;
25
+ const CHUNK_MS = 2 * 60 * 60 * 1000;
26
26
  const CHUNK_PAUSE_MS = 200;
27
27
 
28
+ // A query that times out is retried rather than failing the whole replay
29
+ // outright -- a Raspberry Pi sharing its InfluxDB with Signal K itself is
30
+ // often just busy for a moment, not actually unreachable.
31
+ const QUERY_MAX_RETRIES = 3;
32
+ const QUERY_RETRY_DELAY_MS = 5 * 1000;
33
+
28
34
  // The motion scan asks for one mean per minute, light enough to cover a week
29
35
  // per request.
30
36
  const SCAN_BUCKET_MS = 60 * 1000;
@@ -112,7 +118,9 @@ function createInfluxHistory({
112
118
  selfContext,
113
119
  queryTimeoutSeconds = INFLUX_DEFAULTS.influxQueryTimeoutSeconds,
114
120
  fetch = globalThis.fetch,
115
- signal
121
+ signal,
122
+ onRetry = () => {},
123
+ retryDelayMs = QUERY_RETRY_DELAY_MS
116
124
  }) {
117
125
  const queryTimeoutMs = queryTimeoutSeconds * 1000;
118
126
  // path -> [{ time, node }], ascending by time; `node` is what readSelfPath
@@ -136,36 +144,59 @@ function createInfluxHistory({
136
144
  epoch: 'ms',
137
145
  q: statements.join(';')
138
146
  });
139
- const timeout = AbortSignal.timeout(queryTimeoutMs);
140
- let response;
141
- try {
142
- response = await fetch(url, {
143
- method: 'POST',
144
- headers,
145
- body,
146
- signal: signal ? AbortSignal.any([timeout, signal]) : timeout
147
- });
148
- } catch (err) {
149
- if (signal?.aborted) {
150
- throw err;
151
- }
152
- if (err.name === 'TimeoutError') {
147
+
148
+ // One full round trip: the timeout signal covers reading the response
149
+ // body too, not just getting the headers back, so a database that is slow
150
+ // to stream a large chunk's JSON times out here just as it would waiting
151
+ // for the connection -- both must retry the same way.
152
+ async function attempt() {
153
+ const timeout = AbortSignal.timeout(queryTimeoutMs);
154
+ let response;
155
+ try {
156
+ response = await fetch(url, {
157
+ method: 'POST',
158
+ headers,
159
+ body,
160
+ signal: signal ? AbortSignal.any([timeout, signal]) : timeout
161
+ });
162
+ } catch (err) {
163
+ if (signal?.aborted || err.name === 'TimeoutError') {
164
+ throw err;
165
+ }
166
+ // Node's fetch wraps a connection failure (wrong host, refused,
167
+ // certificate…) as a bare "fetch failed"; the actual reason is here.
153
168
  throw new Error(
154
- `InfluxDB at ${protocol}://${host}:${port} did not answer within ${queryTimeoutSeconds}s`,
169
+ `Could not reach InfluxDB at ${protocol}://${host}:${port}: ${err.cause?.message ?? err.message}`,
155
170
  { cause: err }
156
171
  );
157
172
  }
158
- // Node's fetch wraps a connection failure (wrong host, refused,
159
- // certificate…) as a bare "fetch failed"; the actual reason is here.
160
- throw new Error(
161
- `Could not reach InfluxDB at ${protocol}://${host}:${port}: ${err.cause?.message ?? err.message}`,
162
- { cause: err }
163
- );
173
+ if (!response.ok) {
174
+ throw new Error(`InfluxDB query failed: ${response.status} ${await response.text()}`);
175
+ }
176
+ return response.json();
164
177
  }
165
- if (!response.ok) {
166
- throw new Error(`InfluxDB query failed: ${response.status} ${await response.text()}`);
178
+
179
+ let payload;
180
+ // Each attempt gets its own full timeout budget, not a shared one left
181
+ // over from the last -- a query that timed out once is retried outright,
182
+ // not with less time to answer than before.
183
+ for (let retries = 0; ; retries += 1) {
184
+ try {
185
+ payload = await attempt();
186
+ onRetry(null);
187
+ break;
188
+ } catch (err) {
189
+ if (signal?.aborted || err.name !== 'TimeoutError') {
190
+ throw err;
191
+ }
192
+ const message = `InfluxDB at ${protocol}://${host}:${port} did not answer within ${queryTimeoutSeconds}s`;
193
+ if (retries >= QUERY_MAX_RETRIES) {
194
+ throw new Error(message, { cause: err });
195
+ }
196
+ onRetry(retries + 1, QUERY_MAX_RETRIES, message);
197
+ await sleep(retryDelayMs);
198
+ }
167
199
  }
168
- const payload = await response.json();
169
200
  return payload.results.map((result) => {
170
201
  if (result.error) {
171
202
  throw new Error(`InfluxDB query error: ${result.error}`);
@@ -482,4 +513,4 @@ function createInfluxHistory({
482
513
  return { preload, clear, scanMotion, readSelfPath };
483
514
  }
484
515
 
485
- module.exports = { createInfluxHistory, INFLUX_DEFAULTS };
516
+ module.exports = { createInfluxHistory, INFLUX_DEFAULTS, CHUNK_MS, QUERY_MAX_RETRIES };
package/lib/replay-job.js CHANGED
@@ -13,7 +13,8 @@ function createReplayJob({
13
13
  clock = Date.now,
14
14
  log = () => {},
15
15
  fetch = globalThis.fetch,
16
- onDone = () => {}
16
+ onDone = () => {},
17
+ retryDelayMs
17
18
  }) {
18
19
  let current = null;
19
20
  let lastResult = null;
@@ -73,7 +74,19 @@ function createReplayJob({
73
74
  selfContext: settings.influxSelfContext || app.selfContext,
74
75
  queryTimeoutSeconds: settings.influxQueryTimeoutSeconds,
75
76
  fetch,
76
- signal
77
+ signal,
78
+ retryDelayMs,
79
+ onRetry: (attempt, of, message) => {
80
+ if (attempt !== null) {
81
+ log(
82
+ 'error',
83
+ `Retrospective replay: InfluxDB query timed out, retrying (attempt ${attempt}/${of}): ${message}`
84
+ );
85
+ }
86
+ if (current) {
87
+ current.retry = attempt === null ? null : { attempt, of, message };
88
+ }
89
+ }
77
90
  });
78
91
  try {
79
92
  await runWindowedReplay({
@@ -91,6 +104,11 @@ function createReplayJob({
91
104
  onProgress: (nowMs) => {
92
105
  if (current) {
93
106
  current.now = iso(nowMs);
107
+ // Recomputed each slice so the webapp can show what has actually
108
+ // been committed so far, not just how far the clock has got --
109
+ // the only way to know anything was saved if the run then times
110
+ // out or otherwise fails.
111
+ current.summary = summarise(afterId, to);
94
112
  }
95
113
  }
96
114
  });
@@ -105,7 +123,13 @@ function createReplayJob({
105
123
  summary: summarise(afterId, to)
106
124
  };
107
125
  } else {
108
- lastError = { at: iso(clock()), from, to, message: err.message };
126
+ lastError = {
127
+ at: iso(clock()),
128
+ from,
129
+ to,
130
+ message: err.message,
131
+ summary: summarise(afterId, to)
132
+ };
109
133
  log('error', `Retrospective replay failed (${from} to ${to}): ${err.message}`);
110
134
  }
111
135
  } finally {
@@ -141,7 +165,15 @@ function createReplayJob({
141
165
  );
142
166
  }
143
167
  const controller = new AbortController();
144
- current = { from, to, startedAt: iso(clock()), now: from, phase: 'scanning', controller };
168
+ current = {
169
+ from,
170
+ to,
171
+ startedAt: iso(clock()),
172
+ now: from,
173
+ phase: 'scanning',
174
+ retry: null,
175
+ controller
176
+ };
145
177
  lastError = null;
146
178
  perform(from, to, controller.signal);
147
179
  return { from, to };
@@ -163,7 +195,9 @@ function createReplayJob({
163
195
  from: current.from,
164
196
  to: current.to,
165
197
  now: current.now,
166
- phase: current.phase
198
+ phase: current.phase,
199
+ summary: current.summary ?? null,
200
+ retry: current.retry
167
201
  },
168
202
  lastResult,
169
203
  lastError
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -36,7 +36,8 @@
36
36
  "./docs/screenshots/04-entry-app.png"
37
37
  ],
38
38
  "recommends": [
39
- "signalk-to-influxdb"
39
+ "signalk-to-influxdb",
40
+ "signalk-autostate"
40
41
  ]
41
42
  },
42
43
  "author": "Cedric RICARD <ricard33@gmail.com>",
@@ -26,19 +26,35 @@ function Progress({ progress }) {
26
26
  time: `${format.shortDate(progress.now)} ${format.time(progress.now)}`
27
27
  })}
28
28
  </p>
29
+ ${
30
+ progress.retry &&
31
+ html`<p class="notice">
32
+ ${t('replay.retrying', {
33
+ attempt: progress.retry.attempt,
34
+ of: progress.retry.of,
35
+ message: progress.retry.message
36
+ })}
37
+ </p>`
38
+ }
39
+ ${
40
+ progress.phase === 'replaying' &&
41
+ html`<${Summary} summary=${progress.summary} live=${true} />`
42
+ }
29
43
  </div>
30
44
  `;
31
45
  }
32
46
 
33
- // What the latest run added -- also after a cancellation, since what was
34
- // reconstructed up to that point stays on record.
35
- function Summary({ summary }) {
47
+ // What a run added, live while it is still going (so a timeout partway
48
+ // through still leaves something to show for it) and again for the final
49
+ // outcome -- also after a cancellation, since what was reconstructed up to
50
+ // that point stays on record either way.
51
+ function Summary({ summary, live = false }) {
36
52
  const { t, format } = useLocale();
37
53
  if (!summary) {
38
54
  return null;
39
55
  }
40
56
  if (summary.passages === 0) {
41
- return html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
57
+ return live ? null : html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
42
58
  }
43
59
  const facts = [
44
60
  ['replay.summaryPassages', format.count(summary.passages)],
@@ -66,12 +82,15 @@ function Outcome({ status }) {
66
82
  const { t, format } = useLocale();
67
83
  if (status.lastError) {
68
84
  const { lastError } = status;
69
- return html`<p class="notice notice-error">
70
- ${t('replay.failed', {
71
- time: format.time(lastError.at),
72
- message: lastError.message
73
- })}
74
- </p>`;
85
+ return html`
86
+ <p class="notice notice-error">
87
+ ${t('replay.failed', {
88
+ time: format.time(lastError.at),
89
+ message: lastError.message
90
+ })}
91
+ </p>
92
+ <${Summary} summary=${lastError.summary} />
93
+ `;
75
94
  }
76
95
  if (status.lastResult) {
77
96
  const { lastResult } = status;
@@ -351,6 +351,7 @@ export const MESSAGES = {
351
351
  'replay.start': 'Reconstruct',
352
352
  'replay.cancel': 'Cancel',
353
353
  'replay.progress': '{percent}% — at {time}',
354
+ 'replay.retrying': 'Retrying after a timeout (attempt {attempt}/{of}): {message}',
354
355
  'replay.done': 'Reconstruction finished at {time}.',
355
356
  'replay.cancelled': 'Reconstruction cancelled at {time}.',
356
357
  'replay.failed': 'Reconstruction failed at {time}: {message}',
@@ -802,6 +803,8 @@ export const MESSAGES = {
802
803
  'replay.start': 'Reconstruire',
803
804
  'replay.cancel': 'Annuler',
804
805
  'replay.progress': '{percent} % — à {time}',
806
+ 'replay.retrying':
807
+ 'Nouvelle tentative après un délai dépassé (essai {attempt}/{of}) : {message}',
805
808
  'replay.done': 'Reconstruction terminée à {time}.',
806
809
  'replay.cancelled': 'Reconstruction annulée à {time}.',
807
810
  'replay.failed': 'Échec de la reconstruction à {time} : {message}',