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 +23 -1
- package/index.js +24 -2
- package/lib/influx-history.js +59 -28
- package/lib/replay-job.js +39 -5
- package/package.json +3 -2
- package/public/js/components/ReplayView.mjs +29 -10
- package/public/js/i18n.mjs +3 -0
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.
|
|
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', () =>
|
|
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', () =>
|
|
496
|
+
guarded('Event watching', () => {
|
|
497
|
+
if (!replayJob?.status().running) {
|
|
498
|
+
watcher.check();
|
|
499
|
+
}
|
|
500
|
+
}),
|
|
479
501
|
CHECK_INTERVAL_MS
|
|
480
502
|
),
|
|
481
503
|
setInterval(
|
package/lib/influx-history.js
CHANGED
|
@@ -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;
|
|
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 =
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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}
|
|
169
|
+
`Could not reach InfluxDB at ${protocol}://${host}:${port}: ${err.cause?.message ?? err.message}`,
|
|
155
170
|
{ cause: err }
|
|
156
171
|
);
|
|
157
172
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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 = {
|
|
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 = {
|
|
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.
|
|
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
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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;
|
package/public/js/i18n.mjs
CHANGED
|
@@ -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}',
|