signalk-chiplog 2.10.0 → 2.11.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 +23 -1
- package/README.md +14 -1
- package/index.js +19 -0
- package/lib/cloud-sync.js +19 -4
- package/lib/event-watcher.js +31 -1
- package/package.json +1 -1
- package/public/app.css +45 -0
- package/public/entry/js/components/Dialogs.mjs +9 -1
- package/public/entry/js/components/StatusHeader.mjs +39 -6
- package/public/entry/js/main.mjs +75 -39
- package/public/entry/sw.js +1 -0
- package/public/js/api.mjs +18 -2
- package/public/js/components/LogView.mjs +1 -1
- package/public/js/components/StatusBar.mjs +26 -11
- package/public/js/components/common.mjs +114 -5
- package/public/js/connection.mjs +126 -0
- package/public/js/context.mjs +11 -0
- package/public/js/i18n.mjs +29 -2
- package/public/js/main.mjs +6 -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.11.0] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Alarms can be left out of the log.** **Log Signal K alarms** turns them off altogether, and **Alarms left out of the
|
|
14
|
+
log** lists the ones never to record, `*` standing for any characters: `notifications.chartplotter.unknown*` silences
|
|
15
|
+
a chart plotter raising `unknown114`, `unknown115` and so on. An alarm already in the log is still closed when it
|
|
16
|
+
clears.
|
|
17
|
+
- **Reconnecting after the server drops out.** When the Signal K server stops answering, or your login expires, the
|
|
18
|
+
webapp says which in a banner at the bottom of the screen, seen wherever you are on the page, and offers **Reconnect**
|
|
19
|
+
(with **Sign in** for an expired login, in another tab); once the server answers, pages reload on their own and the
|
|
20
|
+
banner says when a failed correction can be done again. On the tablet, **Not connected** gets a **Reconnect** button
|
|
21
|
+
that sends waiting entries at once, and corrections or crew changes that fail offline say so with the same button.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **The online backup sends only what the boat's plan keeps.** When the service says its plan keeps the passages from a
|
|
26
|
+
date on (Ti Bord's free plan: the last 30 days), older closed passages stay on board and are not sent, and the
|
|
27
|
+
passages counted for the backup's progress are those of that window. A passage under way is always sent; once the boat
|
|
28
|
+
is subscribed, everything is sent again, newest first.
|
|
29
|
+
|
|
9
30
|
## [2.10.0] - 2026-10-20
|
|
10
31
|
|
|
11
32
|
### Added
|
|
@@ -462,7 +483,8 @@ First release.
|
|
|
462
483
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
463
484
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
464
485
|
|
|
465
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
486
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...HEAD
|
|
487
|
+
[2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
|
|
466
488
|
[2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
|
|
467
489
|
[2.9.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...v2.9.0
|
|
468
490
|
[2.8.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.7.0...v2.8.0
|
package/README.md
CHANGED
|
@@ -128,7 +128,8 @@ an old value.
|
|
|
128
128
|
Added to the timeline without anyone touching anything:
|
|
129
129
|
|
|
130
130
|
- **Alarms** — any Signal K notification reaching `alarm` or `emergency` (man overboard, engine alarm, anchor watch…),
|
|
131
|
-
and when it clears.
|
|
131
|
+
and when it clears. They can be turned off altogether, or some left out by name: `notifications.chartplotter.unknown*`
|
|
132
|
+
skips `unknown114`, `unknown115` and every other path starting that way.
|
|
132
133
|
- **Autopilot** — engaged, disengaged, mode changes.
|
|
133
134
|
- **Wind** — true wind, averaged over 2 minutes, rising above 20 and 30 knots and falling back below them
|
|
134
135
|
(configurable).
|
|
@@ -260,6 +261,12 @@ shortcuts beside it; where any date is allowed, the calendar has an _Any date_ b
|
|
|
260
261
|
|
|
261
262
|
**Helm entry** in the top bar opens the tablet entry app.
|
|
262
263
|
|
|
264
|
+
**When the server drops out** — Wi-Fi gone, Signal K restarting — a banner at the bottom of the screen says the server
|
|
265
|
+
does not answer and offers **Reconnect**, wherever you are on the page. Once it answers again, pages reload what they
|
|
266
|
+
were showing, and the banner says when a correction that failed can be done again. If your Signal K login has expired
|
|
267
|
+
instead, the message says so, with **Sign in** opening the admin in another tab: sign in there, come back, and the page
|
|
268
|
+
picks up where you left it.
|
|
269
|
+
|
|
263
270
|
### Your own boat in the 3D animation ⛵
|
|
264
271
|
|
|
265
272
|
The 3D view draws a cartoon sailboat. To see yours instead, use **Boat → Use my own boat…** under the animation and pick
|
|
@@ -407,6 +414,10 @@ Keep logging. The header shows **Not connected** and how many entries are waitin
|
|
|
407
414
|
the time it was made, and sent in order as soon as the server answers again, with the position the track recorded at
|
|
408
415
|
that time. An entry sent just before the connection dropped is never logged twice.
|
|
409
416
|
|
|
417
|
+
The app tries again by itself every few seconds; **Reconnect** beside **Not connected** tries at once, and sends what is
|
|
418
|
+
waiting if the server answers. A correction or a crew change cannot wait like an entry: if it fails for want of a
|
|
419
|
+
connection, it says so with a **Reconnect** button, to do again once connected.
|
|
420
|
+
|
|
410
421
|
If the server refuses a waiting entry when it comes back — typically nothing to attach it to — it is shown in red in the
|
|
411
422
|
latest entries, to discard.
|
|
412
423
|
|
|
@@ -459,6 +470,8 @@ In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
|
|
|
459
470
|
| Map tiles for the summary | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | The tiles the summary map is drawn over, e.g. a self-hosted server. Empty draws the track on a plain background. |
|
|
460
471
|
| Logbook language (PDF and summary emails) | en | Language of the PDF logbooks on the USB drive and of the summary emails (English or French). |
|
|
461
472
|
| Ship's time zone (PDF and summary emails) | the server's | Time zone of the PDF logbooks on the USB drive and of the summary emails, e.g. `Europe/Paris`. |
|
|
473
|
+
| Log Signal K alarms | on | Turn off to leave every alarm out of the log. |
|
|
474
|
+
| Alarms left out of the log | — | Notification paths never logged; `*` stands for any characters, and the leading `notifications.` may be omitted. |
|
|
462
475
|
| Wind speed thresholds | 20, 30 kn | Logged when the 2-minute average true wind crosses them. |
|
|
463
476
|
| Barometric drop warning | 4 hPa / 3 h | 0 turns it off. |
|
|
464
477
|
| Log heading changes | on | Turn off to leave heading changes out of the log. |
|
package/index.js
CHANGED
|
@@ -314,6 +314,21 @@ module.exports = function (app) {
|
|
|
314
314
|
description:
|
|
315
315
|
'IANA time zone the PDF logbook on the USB drive and the passage summary emails are kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
|
|
316
316
|
},
|
|
317
|
+
alarmsEnabled: {
|
|
318
|
+
type: 'boolean',
|
|
319
|
+
title: 'Log Signal K alarms',
|
|
320
|
+
description:
|
|
321
|
+
'Records every notification reaching alarm or emergency, when raised and when cleared; turn off to leave alarms out of the log',
|
|
322
|
+
default: EVENT_DEFAULTS.alarmsEnabled
|
|
323
|
+
},
|
|
324
|
+
ignoredAlarms: {
|
|
325
|
+
type: 'array',
|
|
326
|
+
title: 'Alarms left out of the log',
|
|
327
|
+
description:
|
|
328
|
+
'Notification paths never logged, * standing for any characters: notifications.chartplotter.unknown* leaves out unknown114, unknown115 and so on. The leading notifications. may be omitted',
|
|
329
|
+
items: { type: 'string' },
|
|
330
|
+
default: EVENT_DEFAULTS.ignoredAlarms
|
|
331
|
+
},
|
|
317
332
|
windSpeedThresholds: {
|
|
318
333
|
type: 'array',
|
|
319
334
|
title: 'Wind speed thresholds (knots)',
|
|
@@ -647,6 +662,10 @@ module.exports = function (app) {
|
|
|
647
662
|
? config.logbookLanguage
|
|
648
663
|
: 'en',
|
|
649
664
|
logbookTimeZone: config.logbookTimeZone || null,
|
|
665
|
+
alarmsEnabled: config.alarmsEnabled ?? EVENT_DEFAULTS.alarmsEnabled,
|
|
666
|
+
ignoredAlarms: Array.isArray(config.ignoredAlarms)
|
|
667
|
+
? config.ignoredAlarms
|
|
668
|
+
: EVENT_DEFAULTS.ignoredAlarms,
|
|
650
669
|
windSpeedThresholds: config.windSpeedThresholds ?? EVENT_DEFAULTS.windSpeedThresholds,
|
|
651
670
|
pressureDropThreshold: config.pressureDropThreshold ?? EVENT_DEFAULTS.pressureDropThreshold,
|
|
652
671
|
headingChangeEnabled: config.headingChangeEnabled ?? EVENT_DEFAULTS.headingChangeEnabled,
|
package/lib/cloud-sync.js
CHANGED
|
@@ -160,6 +160,9 @@ function createCloudSync({
|
|
|
160
160
|
let progress = null;
|
|
161
161
|
// Set by a backup asked for by hand: the next run sends everything in full, costly link or not.
|
|
162
162
|
let fullRequested = false;
|
|
163
|
+
// The first moment of the logbook the boat's plan keeps on the service, as its sync state last
|
|
164
|
+
// said (`historyFrom`); null for all of it, and before the service has said anything.
|
|
165
|
+
let historyFrom = null;
|
|
163
166
|
|
|
164
167
|
const iso = (ms) => new Date(ms).toISOString();
|
|
165
168
|
const base = () => `${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1/logbooks/${getLogbookId(db)}`;
|
|
@@ -260,15 +263,27 @@ function createCloudSync({
|
|
|
260
263
|
}
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
// A passage the service's plan keeps: begun from `historyFrom`, or still under way. The others
|
|
267
|
+
// stay on board and are not sent; the service keeps no more of them than their hashes anyway.
|
|
268
|
+
const kept = (row) =>
|
|
269
|
+
historyFrom === null || row.end_time === null || Date.parse(row.start_time) >= historyFrom;
|
|
270
|
+
|
|
263
271
|
async function synchronise(costly) {
|
|
264
|
-
const
|
|
265
|
-
|
|
272
|
+
const onBoardRows = db
|
|
273
|
+
.prepare('SELECT * FROM log_entries ORDER BY start_time DESC, id DESC')
|
|
274
|
+
.all();
|
|
275
|
+
// How many passages are on board for the service to keep: it tells its owner how far the
|
|
276
|
+
// backup is. Counted by the window it last gave, the first run by none.
|
|
266
277
|
const state = await call('GET', '/sync-state', undefined, {
|
|
267
|
-
'x-chiplog-passages': String(
|
|
278
|
+
'x-chiplog-passages': String(onBoardRows.filter(kept).length)
|
|
268
279
|
});
|
|
280
|
+
const from = typeof state.historyFrom === 'string' ? Date.parse(state.historyFrom) : NaN;
|
|
281
|
+
historyFrom = Number.isNaN(from) ? null : from;
|
|
282
|
+
const rows = onBoardRows.filter(kept);
|
|
269
283
|
const remote = state.passages;
|
|
270
284
|
const held = new Map(remote.map((item) => [item.originId, item]));
|
|
271
|
-
|
|
285
|
+
// Passages left out by the plan are still on board: the service is not told to delete them.
|
|
286
|
+
const onBoard = new Set(onBoardRows.map((row) => row.id));
|
|
272
287
|
for (const id of cache.keys()) {
|
|
273
288
|
if (!onBoard.has(id)) {
|
|
274
289
|
cache.delete(id);
|
package/lib/event-watcher.js
CHANGED
|
@@ -5,6 +5,8 @@ const { CRITICAL_STATES } = require('./events');
|
|
|
5
5
|
const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
|
|
6
6
|
|
|
7
7
|
const EVENT_DEFAULTS = {
|
|
8
|
+
alarmsEnabled: true,
|
|
9
|
+
ignoredAlarms: [],
|
|
8
10
|
windSpeedThresholds: [20, 30],
|
|
9
11
|
pressureDropThreshold: 4,
|
|
10
12
|
headingChangeEnabled: true,
|
|
@@ -88,8 +90,30 @@ function collectNotifications(node, path, found, depth = 0) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// A pattern names a notification path, `*` standing for any run of
|
|
94
|
+
// characters (dots included); the leading `notifications.` may be left out.
|
|
95
|
+
function compileAlarmPattern(pattern) {
|
|
96
|
+
const trimmed = String(pattern).trim();
|
|
97
|
+
if (trimmed === '') {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const full = trimmed.startsWith('notifications.') ? trimmed : `notifications.${trimmed}`;
|
|
101
|
+
const source = full
|
|
102
|
+
.split('*')
|
|
103
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
104
|
+
.join('.*');
|
|
105
|
+
return new RegExp(`^${source}$`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function createAlarmFilter(patterns) {
|
|
109
|
+
const compiled = (patterns ?? []).map(compileAlarmPattern).filter(Boolean);
|
|
110
|
+
return (path) => compiled.some((pattern) => pattern.test(path));
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.now }) {
|
|
92
114
|
const readFresh = createFreshnessTracker(readSelfPath);
|
|
115
|
+
const alarmsEnabled = settings.alarmsEnabled ?? EVENT_DEFAULTS.alarmsEnabled;
|
|
116
|
+
const isIgnoredAlarm = createAlarmFilter(settings.ignoredAlarms);
|
|
93
117
|
|
|
94
118
|
const seenNotifications = new Map();
|
|
95
119
|
const loggedNotifications = new Map();
|
|
@@ -173,6 +197,12 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
|
|
|
173
197
|
|
|
174
198
|
const logged = lastLoggedState(path);
|
|
175
199
|
const critical = CRITICAL_STATES.has(state);
|
|
200
|
+
// Alarms turned off, or this one left out, are not raised in the log;
|
|
201
|
+
// one already logged is still closed when it clears, so no raise is
|
|
202
|
+
// left without its end.
|
|
203
|
+
if (critical && (!alarmsEnabled || isIgnoredAlarm(path))) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
176
206
|
// Log a critical state not yet logged, or the end of one that was; a
|
|
177
207
|
// clearing whose alarm never made it into the log is not news.
|
|
178
208
|
const worthLogging = critical ? logged !== state : CRITICAL_STATES.has(logged);
|
|
@@ -467,4 +497,4 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
|
|
|
467
497
|
};
|
|
468
498
|
}
|
|
469
499
|
|
|
470
|
-
module.exports = { createEventWatcher, EVENT_DEFAULTS, CHECK_INTERVAL_MS };
|
|
500
|
+
module.exports = { createEventWatcher, createAlarmFilter, EVENT_DEFAULTS, CHECK_INTERVAL_MS };
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -251,6 +251,51 @@ main {
|
|
|
251
251
|
border-color: transparent;
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
.notice-actions {
|
|
255
|
+
display: flex;
|
|
256
|
+
flex-wrap: wrap;
|
|
257
|
+
align-items: center;
|
|
258
|
+
gap: 0.75rem;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* Above the map's controls, wherever the page is scrolled. */
|
|
262
|
+
.connection-toast {
|
|
263
|
+
position: fixed;
|
|
264
|
+
left: 50%;
|
|
265
|
+
bottom: 1rem;
|
|
266
|
+
z-index: 1100;
|
|
267
|
+
transform: translateX(-50%);
|
|
268
|
+
width: min(40rem, calc(100% - 2rem));
|
|
269
|
+
box-sizing: border-box;
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-wrap: wrap;
|
|
272
|
+
align-items: center;
|
|
273
|
+
justify-content: space-between;
|
|
274
|
+
gap: 0.5rem 1rem;
|
|
275
|
+
padding: 0.75rem 1rem;
|
|
276
|
+
border-radius: var(--radius);
|
|
277
|
+
background: var(--error-bg);
|
|
278
|
+
color: var(--text);
|
|
279
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.connection-toast p {
|
|
283
|
+
margin: 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.connection-toast-ok {
|
|
287
|
+
background: var(--ok-bg);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.connection-toast .toast-close {
|
|
291
|
+
border: none;
|
|
292
|
+
background: none;
|
|
293
|
+
color: inherit;
|
|
294
|
+
font-size: 1.4rem;
|
|
295
|
+
line-height: 1;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
}
|
|
298
|
+
|
|
254
299
|
.notice-ok {
|
|
255
300
|
background: var(--ok-bg);
|
|
256
301
|
border-color: transparent;
|
|
@@ -67,7 +67,7 @@ export function EventDialog({ title, event, bounds, onSave, onCancel }) {
|
|
|
67
67
|
// say the entry is in, a countdown shows the banner will go on its own, and the
|
|
68
68
|
// two follow-ups are secondary next to it — undoing is the destructive one, and
|
|
69
69
|
// says what it undoes, since on its own "undo" reads as dismissing the banner.
|
|
70
|
-
export function Toast({ toast, timeoutMs, onUndo, onComment, onClose }) {
|
|
70
|
+
export function Toast({ toast, timeoutMs, onUndo, onComment, onClose, onReconnect }) {
|
|
71
71
|
const { t, format } = useLocale();
|
|
72
72
|
if (!toast) {
|
|
73
73
|
return null;
|
|
@@ -85,6 +85,14 @@ export function Toast({ toast, timeoutMs, onUndo, onComment, onClose }) {
|
|
|
85
85
|
return html`
|
|
86
86
|
<div class="toast toast-error" role="alert">
|
|
87
87
|
<span class="toast-text">${toast.message}</span>
|
|
88
|
+
${
|
|
89
|
+
toast.reconnect &&
|
|
90
|
+
html`<span class="toast-actions">
|
|
91
|
+
<button type="button" class="tool-button" onClick=${onReconnect}>
|
|
92
|
+
${t('connection.reconnect')}
|
|
93
|
+
</button>
|
|
94
|
+
</span>`
|
|
95
|
+
}
|
|
88
96
|
${close}
|
|
89
97
|
</div>
|
|
90
98
|
`;
|
|
@@ -34,9 +34,40 @@ function passageLine(entry, t, format) {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// While the server does not answer, the connection mark is a button that
|
|
38
|
+
// tries again at once, rather than at the next poll.
|
|
39
|
+
function ConnectionMark({ online, connecting, queued, onReconnect }) {
|
|
40
|
+
const { t } = useLocale();
|
|
39
41
|
const connection = online ? t('entry.online') : t('entry.offline');
|
|
42
|
+
const label = queued > 0 ? `${connection} · ${t('entry.queued', { count: queued })}` : connection;
|
|
43
|
+
if (online) {
|
|
44
|
+
return html`<span class="connection connection-online">
|
|
45
|
+
<span class="connection-dot" aria-hidden="true"></span>
|
|
46
|
+
${label}
|
|
47
|
+
</span>`;
|
|
48
|
+
}
|
|
49
|
+
return html`
|
|
50
|
+
<span class="connection connection-offline">
|
|
51
|
+
<span class="connection-dot" aria-hidden="true"></span>
|
|
52
|
+
${label}
|
|
53
|
+
<button type="button" class="tool-button" disabled=${connecting} onClick=${onReconnect}>
|
|
54
|
+
${connecting ? t('entry.reconnecting') : t('connection.reconnect')}
|
|
55
|
+
</button>
|
|
56
|
+
</span>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function StatusHeader({
|
|
61
|
+
state,
|
|
62
|
+
entry,
|
|
63
|
+
online,
|
|
64
|
+
connecting,
|
|
65
|
+
queued,
|
|
66
|
+
night,
|
|
67
|
+
onToggleNight,
|
|
68
|
+
onReconnect
|
|
69
|
+
}) {
|
|
70
|
+
const { t, format } = useLocale();
|
|
40
71
|
return html`
|
|
41
72
|
<header class="entry-header">
|
|
42
73
|
<div class="entry-status">
|
|
@@ -50,10 +81,12 @@ export function StatusHeader({ state, entry, online, queued, night, onToggleNigh
|
|
|
50
81
|
}
|
|
51
82
|
</div>
|
|
52
83
|
<div class="entry-tools">
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
<${ConnectionMark}
|
|
85
|
+
online=${online}
|
|
86
|
+
connecting=${connecting}
|
|
87
|
+
queued=${queued}
|
|
88
|
+
onReconnect=${onReconnect}
|
|
89
|
+
/>
|
|
57
90
|
<button
|
|
58
91
|
type="button"
|
|
59
92
|
class="tool-button"
|
package/public/entry/js/main.mjs
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { html, render, useEffect, useMemo, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
-
import { fetchAll, get, onResponse, request } from '../../js/api.mjs';
|
|
3
|
-
import {
|
|
2
|
+
import { connection, fetchAll, get, onResponse, request } from '../../js/api.mjs';
|
|
3
|
+
import { connectionProblem, watchConnection } from '../../js/connection.mjs';
|
|
4
|
+
import {
|
|
5
|
+
createLocale,
|
|
6
|
+
LocaleProvider,
|
|
7
|
+
useConnection,
|
|
8
|
+
useLocale,
|
|
9
|
+
usePolling
|
|
10
|
+
} from '../../js/context.mjs';
|
|
4
11
|
import { randomId } from '../../js/ids.mjs';
|
|
5
12
|
import { pickLanguage } from '../../js/i18n.mjs';
|
|
6
13
|
import { createServerClock } from './clock.mjs';
|
|
@@ -80,10 +87,8 @@ function writeSetting(key, value) {
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
const clock = createServerClock();
|
|
83
|
-
let markOnline = () => {};
|
|
84
90
|
onResponse((response, sentAt, receivedAt) => {
|
|
85
91
|
clock.observe(response.headers.get('date'), sentAt, receivedAt);
|
|
86
|
-
markOnline(true);
|
|
87
92
|
});
|
|
88
93
|
|
|
89
94
|
function App({ journal }) {
|
|
@@ -93,7 +98,7 @@ function App({ journal }) {
|
|
|
93
98
|
const [lastEntryId, setLastEntryId] = useState(null);
|
|
94
99
|
const [types, setTypes] = useState(knownTypes);
|
|
95
100
|
const [roster, setRoster] = useState(knownCrew);
|
|
96
|
-
const
|
|
101
|
+
const link = useConnection();
|
|
97
102
|
const [forbidden, setForbidden] = useState(false);
|
|
98
103
|
const [queue, setQueue] = useState(journal.outbox.snapshot());
|
|
99
104
|
const [toast, setToast] = useState(null);
|
|
@@ -104,18 +109,51 @@ function App({ journal }) {
|
|
|
104
109
|
const [version, setVersion] = useState(0);
|
|
105
110
|
const [busy, setBusy] = useState(false);
|
|
106
111
|
|
|
107
|
-
markOnline = setOnline;
|
|
108
112
|
const refresh = () => setVersion((v) => v + 1);
|
|
113
|
+
// The link itself is followed by `connection` (api.mjs), which every request
|
|
114
|
+
// reports to; a refusal is this tablet lacking access, which the gate asks for.
|
|
115
|
+
const online = link.state !== 'offline';
|
|
109
116
|
|
|
110
117
|
const noteFailure = (error) => {
|
|
111
|
-
if (error?.code === 'network') {
|
|
112
|
-
setOnline(false);
|
|
113
|
-
}
|
|
114
118
|
if (error?.code === 'forbidden') {
|
|
115
119
|
setForbidden(true);
|
|
116
120
|
}
|
|
117
121
|
};
|
|
118
122
|
|
|
123
|
+
// Shows what was not done. Entries are queued rather than refused when the
|
|
124
|
+
// link is down, so this is for corrections and the crew list.
|
|
125
|
+
const showFailure = (error) => {
|
|
126
|
+
noteFailure(error);
|
|
127
|
+
const offline = connectionProblem(error) === 'offline';
|
|
128
|
+
setToast({
|
|
129
|
+
kind: 'error',
|
|
130
|
+
reconnect: offline,
|
|
131
|
+
message: offline ? t('entry.notDoneOffline') : t('entry.refused', { message: error.message })
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const flushQueue = async () => {
|
|
136
|
+
if (journal.outbox.size === 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const outcome = await journal.flush();
|
|
140
|
+
noteFailure(outcome.blocked);
|
|
141
|
+
if (outcome.sent.length > 0) {
|
|
142
|
+
refresh();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Asked for by the crew: says so when the server still does not answer,
|
|
147
|
+
// since nothing else on screen would change.
|
|
148
|
+
const reconnect = async () => {
|
|
149
|
+
const state = await connection.check();
|
|
150
|
+
if (state === 'offline') {
|
|
151
|
+
setToast({ kind: 'error', reconnect: true, message: t('entry.stillOffline') });
|
|
152
|
+
} else if (toast?.reconnect) {
|
|
153
|
+
setToast(null);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
119
157
|
useEffect(() => {
|
|
120
158
|
journal.setListener(setQueue);
|
|
121
159
|
}, []);
|
|
@@ -164,7 +202,7 @@ function App({ journal }) {
|
|
|
164
202
|
.catch(noteFailure);
|
|
165
203
|
},
|
|
166
204
|
0,
|
|
167
|
-
[forbidden,
|
|
205
|
+
[forbidden, link.restored]
|
|
168
206
|
);
|
|
169
207
|
|
|
170
208
|
usePolling(
|
|
@@ -179,30 +217,28 @@ function App({ journal }) {
|
|
|
179
217
|
.catch(noteFailure);
|
|
180
218
|
},
|
|
181
219
|
0,
|
|
182
|
-
[forbidden,
|
|
220
|
+
[forbidden, link.restored]
|
|
183
221
|
);
|
|
184
222
|
|
|
185
223
|
// Replays the queue while it holds anything, and as soon as the network is back.
|
|
186
224
|
useEffect(() => {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const outcome = await journal.flush();
|
|
192
|
-
noteFailure(outcome.blocked);
|
|
193
|
-
if (outcome.sent.length > 0) {
|
|
194
|
-
refresh();
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
const timer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
198
|
-
window.addEventListener('online', flush);
|
|
199
|
-
flush();
|
|
225
|
+
const timer = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
|
226
|
+
window.addEventListener('online', flushQueue);
|
|
200
227
|
return () => {
|
|
201
228
|
clearInterval(timer);
|
|
202
|
-
window.removeEventListener('online',
|
|
229
|
+
window.removeEventListener('online', flushQueue);
|
|
203
230
|
};
|
|
204
231
|
}, []);
|
|
205
232
|
|
|
233
|
+
// And whenever the link comes back, along with the state it missed.
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
flushQueue();
|
|
236
|
+
if (link.restored > 0) {
|
|
237
|
+
setForbidden(false);
|
|
238
|
+
refresh();
|
|
239
|
+
}
|
|
240
|
+
}, [link.restored]);
|
|
241
|
+
|
|
206
242
|
const manoeuvreLabels = useMemo(
|
|
207
243
|
() => Object.fromEntries(types.map((type) => [type.key, type.label])),
|
|
208
244
|
[types]
|
|
@@ -266,8 +302,7 @@ function App({ journal }) {
|
|
|
266
302
|
}
|
|
267
303
|
refresh();
|
|
268
304
|
} catch (error) {
|
|
269
|
-
|
|
270
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
305
|
+
showFailure(error);
|
|
271
306
|
}
|
|
272
307
|
};
|
|
273
308
|
|
|
@@ -279,8 +314,7 @@ function App({ journal }) {
|
|
|
279
314
|
await request('DELETE', `/events/${event.id}`);
|
|
280
315
|
refresh();
|
|
281
316
|
} catch (error) {
|
|
282
|
-
|
|
283
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
317
|
+
showFailure(error);
|
|
284
318
|
}
|
|
285
319
|
};
|
|
286
320
|
|
|
@@ -295,8 +329,7 @@ function App({ journal }) {
|
|
|
295
329
|
setManagingCrew(false);
|
|
296
330
|
refresh();
|
|
297
331
|
} catch (error) {
|
|
298
|
-
|
|
299
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
332
|
+
showFailure(error);
|
|
300
333
|
}
|
|
301
334
|
};
|
|
302
335
|
|
|
@@ -306,8 +339,7 @@ function App({ journal }) {
|
|
|
306
339
|
setRoster((prev) => [...prev, member]);
|
|
307
340
|
return member;
|
|
308
341
|
} catch (error) {
|
|
309
|
-
|
|
310
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
342
|
+
showFailure(error);
|
|
311
343
|
return null;
|
|
312
344
|
}
|
|
313
345
|
};
|
|
@@ -317,8 +349,7 @@ function App({ journal }) {
|
|
|
317
349
|
const updated = await request('PATCH', `/crew/${id}`, patch);
|
|
318
350
|
setRoster((prev) => prev.map((member) => (member.id === id ? updated : member)));
|
|
319
351
|
} catch (error) {
|
|
320
|
-
|
|
321
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
352
|
+
showFailure(error);
|
|
322
353
|
}
|
|
323
354
|
};
|
|
324
355
|
|
|
@@ -328,8 +359,7 @@ function App({ journal }) {
|
|
|
328
359
|
setRoster((prev) => prev.filter((member) => member.id !== id));
|
|
329
360
|
return true;
|
|
330
361
|
} catch (error) {
|
|
331
|
-
|
|
332
|
-
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
362
|
+
showFailure(error);
|
|
333
363
|
return false;
|
|
334
364
|
}
|
|
335
365
|
};
|
|
@@ -342,17 +372,20 @@ function App({ journal }) {
|
|
|
342
372
|
state=${state}
|
|
343
373
|
entry=${entry}
|
|
344
374
|
online=${online}
|
|
375
|
+
connecting=${link.checking}
|
|
376
|
+
onReconnect=${reconnect}
|
|
345
377
|
queued=${queue.pending.length}
|
|
346
378
|
night=${night}
|
|
347
379
|
onToggleNight=${() => setNight((value) => !value)}
|
|
348
380
|
/>
|
|
349
381
|
<main class="entry-main">
|
|
350
382
|
${
|
|
351
|
-
forbidden &&
|
|
383
|
+
(forbidden || link.state === 'signedOut') &&
|
|
352
384
|
html`<${AccessGate}
|
|
353
385
|
onGranted=${() => {
|
|
354
386
|
setForbidden(false);
|
|
355
|
-
|
|
387
|
+
connection.check();
|
|
388
|
+
flushQueue();
|
|
356
389
|
refresh();
|
|
357
390
|
}}
|
|
358
391
|
/>`
|
|
@@ -429,6 +462,7 @@ function App({ journal }) {
|
|
|
429
462
|
timeoutMs=${TOAST_MS}
|
|
430
463
|
onClose=${() => setToast(null)}
|
|
431
464
|
onUndo=${undo}
|
|
465
|
+
onReconnect=${reconnect}
|
|
432
466
|
onComment=${() =>
|
|
433
467
|
setEditing({ ref: toast.ref, what: toast.what, event: journal.entryFor(toast.ref) })}
|
|
434
468
|
/>
|
|
@@ -479,6 +513,8 @@ journal.setListener = (next) => {
|
|
|
479
513
|
listener = next;
|
|
480
514
|
};
|
|
481
515
|
|
|
516
|
+
watchConnection(connection, { window, document });
|
|
517
|
+
|
|
482
518
|
render(
|
|
483
519
|
html`<${LocaleProvider} value=${locale}><${App} journal=${journal} /></${LocaleProvider}>`,
|
|
484
520
|
document.getElementById('app')
|
package/public/entry/sw.js
CHANGED
package/public/js/api.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deviceToken } from './auth.mjs';
|
|
2
|
+
import { createConnection } from './connection.mjs';
|
|
2
3
|
|
|
3
4
|
const BASE = '/plugins/signalk-chiplog/api';
|
|
5
|
+
const PROBE_TIMEOUT_MS = 10 * 1000;
|
|
4
6
|
|
|
5
7
|
export class RequestError extends Error {
|
|
6
8
|
constructor(status, code, message) {
|
|
@@ -23,6 +25,17 @@ export function onResponse(listener) {
|
|
|
23
25
|
return () => responseListeners.delete(listener);
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// The link to the server, as every request finds it. Its probe is the cheapest
|
|
29
|
+
// read there is, and one any client with access may make.
|
|
30
|
+
export const connection = createConnection({
|
|
31
|
+
probe: () => request('GET', '/state', undefined, { timeoutMs: PROBE_TIMEOUT_MS })
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function fail(error) {
|
|
35
|
+
connection.report(error);
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export async function request(method, path, body, { timeoutMs } = {}) {
|
|
27
40
|
const headers = body === undefined ? {} : { 'content-type': 'application/json' };
|
|
28
41
|
const token = deviceToken.get();
|
|
@@ -44,12 +57,13 @@ export async function request(method, path, body, { timeoutMs } = {}) {
|
|
|
44
57
|
signal: controller?.signal
|
|
45
58
|
});
|
|
46
59
|
} catch (err) {
|
|
47
|
-
throw new RequestError(0, 'network', err.message);
|
|
60
|
+
throw fail(new RequestError(0, 'network', err.message));
|
|
48
61
|
} finally {
|
|
49
62
|
clearTimeout(timer);
|
|
50
63
|
}
|
|
51
64
|
const receivedAt = Date.now();
|
|
52
65
|
responseListeners.forEach((listener) => listener(response, sentAt, receivedAt));
|
|
66
|
+
connection.observe(response.status);
|
|
53
67
|
|
|
54
68
|
if (response.status === 204) {
|
|
55
69
|
return null;
|
|
@@ -65,7 +79,9 @@ export async function request(method, path, body, { timeoutMs } = {}) {
|
|
|
65
79
|
response.status === 401 || response.status === 403
|
|
66
80
|
? 'forbidden'
|
|
67
81
|
: (data?.error?.code ?? 'http_error');
|
|
68
|
-
throw
|
|
82
|
+
throw fail(
|
|
83
|
+
new RequestError(response.status, code, data?.error?.message ?? response.statusText)
|
|
84
|
+
);
|
|
69
85
|
}
|
|
70
86
|
return data;
|
|
71
87
|
}
|
|
@@ -178,7 +178,7 @@ export function LogView() {
|
|
|
178
178
|
|
|
179
179
|
return html`
|
|
180
180
|
<h1 class="page-title" tabindex="-1" ref=${title}>${t('log.title')}</h1>
|
|
181
|
-
<${ErrorNotice} error=${error} />
|
|
181
|
+
<${ErrorNotice} error=${error} onRetry=${() => reload()} />
|
|
182
182
|
<${LogStats} stats=${log.stats} />
|
|
183
183
|
${groupByDay(log.items).map(
|
|
184
184
|
(day) => html`
|
|
@@ -2,6 +2,7 @@ import { html, useState } from '../../vendor/preact-htm.mjs';
|
|
|
2
2
|
import { get } from '../api.mjs';
|
|
3
3
|
import { useLocale, usePolling } from '../context.mjs';
|
|
4
4
|
import { fallbackMessage } from '../status.mjs';
|
|
5
|
+
import { connectionProblem } from '../connection.mjs';
|
|
5
6
|
import { ErrorNotice } from './common.mjs';
|
|
6
7
|
|
|
7
8
|
const REFRESH_MS = 15 * 1000;
|
|
@@ -20,20 +21,34 @@ export function StatusBar() {
|
|
|
20
21
|
const { t, format } = useLocale();
|
|
21
22
|
const [state, setState] = useState(null);
|
|
22
23
|
const [error, setError] = useState(null);
|
|
24
|
+
const [attempt, setAttempt] = useState(0);
|
|
23
25
|
|
|
24
|
-
usePolling(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
usePolling(
|
|
27
|
+
(isCurrent) => {
|
|
28
|
+
get('/state')
|
|
29
|
+
.then((next) => {
|
|
30
|
+
if (isCurrent()) {
|
|
31
|
+
setState(next);
|
|
32
|
+
setError(null);
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
.catch((err) => isCurrent() && setError(err));
|
|
36
|
+
},
|
|
37
|
+
REFRESH_MS,
|
|
38
|
+
[attempt]
|
|
39
|
+
);
|
|
34
40
|
|
|
41
|
+
// A lost link is the connection toast's to tell; the bar keeps what it last
|
|
42
|
+
// knew, and its notice only reloads it once the link is back.
|
|
43
|
+
if (error && connectionProblem(error) && state) {
|
|
44
|
+
return html`<div class=${`statusbar statusbar-${state.motion}`}>
|
|
45
|
+
<${ErrorNotice} error=${error} onRetry=${() => setAttempt((n) => n + 1)} />
|
|
46
|
+
<span class="status-dot" aria-hidden="true"></span>
|
|
47
|
+
<strong>${motionLabel(state, t)}</strong>
|
|
48
|
+
</div>`;
|
|
49
|
+
}
|
|
35
50
|
if (error) {
|
|
36
|
-
return html`<div class="statusbar"><${ErrorNotice} error=${error} /></div>`;
|
|
51
|
+
return html`<div class="statusbar"><${ErrorNotice} error=${error} onRetry=${() => setAttempt((n) => n + 1)} /></div>`;
|
|
37
52
|
}
|
|
38
53
|
if (!state) {
|
|
39
54
|
return html`<div class="statusbar">${t('common.loading')}</div>`;
|
|
@@ -1,18 +1,127 @@
|
|
|
1
|
-
import { html } from '../../vendor/preact-htm.mjs';
|
|
2
|
-
import {
|
|
1
|
+
import { html, useEffect, useMemo, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { connection } from '../api.mjs';
|
|
3
|
+
import { connectionProblem } from '../connection.mjs';
|
|
4
|
+
import { useConnection, useLocale } from '../context.mjs';
|
|
5
|
+
|
|
6
|
+
// Signing in happens in the Signal K admin, on a tab of its own so that this
|
|
7
|
+
// page keeps what it showed; coming back to it checks the link again.
|
|
8
|
+
export function SignInLink({ label }) {
|
|
9
|
+
return html`<a href="/admin/#/login" target="_blank" rel="noopener">${label}</a>`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Failed actions waiting for the link to come back, so the toast can say, once
|
|
13
|
+
// it does, that there is something to do again.
|
|
14
|
+
const unfinished = new Set();
|
|
15
|
+
|
|
16
|
+
const RESTORED_TOAST_MS = 6000;
|
|
17
|
+
|
|
18
|
+
// One toast for the whole page while the server is out of reach, fixed to the
|
|
19
|
+
// bottom of the screen so it is seen wherever the reader is, however many
|
|
20
|
+
// requests failed: why, and a way to get the link back.
|
|
21
|
+
export function ConnectionToast() {
|
|
22
|
+
const { t } = useLocale();
|
|
23
|
+
const link = useConnection();
|
|
24
|
+
// A server that is down refuses at once: without a word, a reconnect that
|
|
25
|
+
// failed would look like a button that does nothing.
|
|
26
|
+
const [failed, setFailed] = useState(false);
|
|
27
|
+
const [restored, setRestored] = useState(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (link.restored === 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
setFailed(false);
|
|
34
|
+
setRestored({ again: unfinished.size > 0 });
|
|
35
|
+
const timer = setTimeout(() => setRestored(null), RESTORED_TOAST_MS);
|
|
36
|
+
return () => clearTimeout(timer);
|
|
37
|
+
}, [link.restored]);
|
|
38
|
+
|
|
39
|
+
if (link.state === 'connected') {
|
|
40
|
+
if (!restored) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const message = restored.again
|
|
44
|
+
? `${t('connection.restored')} ${t('connection.tryAgain')}`
|
|
45
|
+
: t('connection.restored');
|
|
46
|
+
return html`
|
|
47
|
+
<div class="connection-toast connection-toast-ok" role="status">
|
|
48
|
+
<p>${message}</p>
|
|
49
|
+
<button type="button" class="toast-close" aria-label=${t('common.close')} onClick=${() => setRestored(null)}>×</button>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
const signedOut = link.state === 'signedOut';
|
|
54
|
+
let message = t(signedOut ? 'connection.signedOut' : 'connection.offline');
|
|
55
|
+
if (link.checking) {
|
|
56
|
+
message = t('connection.checking');
|
|
57
|
+
} else if (failed) {
|
|
58
|
+
message = `${t('connection.stillDown')} ${message}`;
|
|
59
|
+
}
|
|
60
|
+
const reconnect = async () => setFailed((await connection.check()) !== 'connected');
|
|
61
|
+
return html`
|
|
62
|
+
<div class="connection-toast" role="alert">
|
|
63
|
+
<p>${message}</p>
|
|
64
|
+
<p class="notice-actions">
|
|
65
|
+
${signedOut && html`<${SignInLink} label=${t('connection.signIn')} />`}
|
|
66
|
+
<button type="button" disabled=${link.checking} onClick=${reconnect}>
|
|
67
|
+
${t('connection.reconnect')}
|
|
68
|
+
</button>
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Where a request failed for want of the link, the toast speaks; here only
|
|
75
|
+
// what the place itself needs. A notice that can redo its request does so once
|
|
76
|
+
// the link is back, whoever brought it back; one that cannot is counted for the
|
|
77
|
+
// toast to say so.
|
|
78
|
+
function ConnectionNotice({ error, problem, link, since, onRetry }) {
|
|
79
|
+
const { t } = useLocale();
|
|
80
|
+
const back = link.state === 'connected' && !link.checking;
|
|
81
|
+
// Back, after a 401 that the probe got past: the account lacks the rights.
|
|
82
|
+
const refused = back && problem === 'signedOut' && link.restored === since;
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (onRetry) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
unfinished.add(error);
|
|
89
|
+
return () => unfinished.delete(error);
|
|
90
|
+
}, [error]);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (back && !refused && onRetry) {
|
|
94
|
+
onRetry();
|
|
95
|
+
}
|
|
96
|
+
}, [back, refused]);
|
|
97
|
+
|
|
98
|
+
if (!refused) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return html`
|
|
102
|
+
<div class="notice notice-error" role="alert">
|
|
103
|
+
<p>${t('error.forbidden')} <${SignInLink} label=${t('error.signIn')} /></p>
|
|
104
|
+
</div>
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
3
107
|
|
|
4
108
|
export function ErrorNotice({ error, onRetry }) {
|
|
5
109
|
const { t } = useLocale();
|
|
110
|
+
const link = useConnection();
|
|
111
|
+
const problem = connectionProblem(error);
|
|
112
|
+
// How many times the link had come back when this error arrived.
|
|
113
|
+
const since = useMemo(() => link.restored, [error]);
|
|
6
114
|
if (!error) {
|
|
7
115
|
return null;
|
|
8
116
|
}
|
|
117
|
+
if (problem) {
|
|
118
|
+
return html`<${ConnectionNotice} error=${error} problem=${problem} link=${link} since=${since} onRetry=${onRetry} />`;
|
|
119
|
+
}
|
|
9
120
|
let message;
|
|
10
121
|
if (error.code === 'forbidden') {
|
|
11
|
-
message = html`${t('error.forbidden')}
|
|
122
|
+
message = html`${t('error.forbidden')} <${SignInLink} label=${t('error.signIn')} />`;
|
|
12
123
|
} else if (error.code === 'plugin_not_started') {
|
|
13
124
|
message = t('error.notRunning');
|
|
14
|
-
} else if (error.code === 'network') {
|
|
15
|
-
message = t('error.network');
|
|
16
125
|
} else {
|
|
17
126
|
message = t('error.generic', { message: error.message });
|
|
18
127
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The state of the link to the Signal K server, shared by everything on the
|
|
2
|
+
// page so that every message about a lost connection agrees, and one reconnect
|
|
3
|
+
// serves them all.
|
|
4
|
+
//
|
|
5
|
+
// States: connected; offline (the server does not answer: the network dropped,
|
|
6
|
+
// or it is restarting); signedOut (it answers but no longer knows us: the login
|
|
7
|
+
// expired, or the device token was revoked). `checking` is true while a probe
|
|
8
|
+
// runs, and `restored` counts the returns to connected, so a message shown
|
|
9
|
+
// before one can tell that the link has come back since.
|
|
10
|
+
|
|
11
|
+
// Which kind of connection loss an API error stands for, or null when it is
|
|
12
|
+
// about the request itself.
|
|
13
|
+
export function connectionProblem(error) {
|
|
14
|
+
if (!error) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (error.code === 'network') {
|
|
18
|
+
return 'offline';
|
|
19
|
+
}
|
|
20
|
+
// A proxy in front of a server that is down answers for it.
|
|
21
|
+
if ((error.status === 502 || error.status === 504) && error.code === 'http_error') {
|
|
22
|
+
return 'offline';
|
|
23
|
+
}
|
|
24
|
+
// Signal K answers 401 to a missing or expired login and to an account that
|
|
25
|
+
// lacks the rights alike: only a probe tells them apart.
|
|
26
|
+
return error.status === 401 ? 'signedOut' : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `probe` makes a read every client with access may make, and rejects with the
|
|
30
|
+
// API's error.
|
|
31
|
+
export function createConnection({ probe }) {
|
|
32
|
+
let state = 'connected';
|
|
33
|
+
let checking = null;
|
|
34
|
+
let restored = 0;
|
|
35
|
+
const listeners = new Set();
|
|
36
|
+
|
|
37
|
+
const snapshot = () => ({ state, checking: checking !== null, restored });
|
|
38
|
+
const notify = () => {
|
|
39
|
+
const current = snapshot();
|
|
40
|
+
listeners.forEach((listener) => listener(current));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function settle(next) {
|
|
44
|
+
if (next === 'connected' && state !== 'connected') {
|
|
45
|
+
restored += 1;
|
|
46
|
+
}
|
|
47
|
+
state = next;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function check() {
|
|
51
|
+
if (checking) {
|
|
52
|
+
return checking;
|
|
53
|
+
}
|
|
54
|
+
checking = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
await probe();
|
|
57
|
+
settle('connected');
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// An answer that is not about the link (the plugin stopped, say)
|
|
60
|
+
// still means the server is there and lets us in.
|
|
61
|
+
settle(connectionProblem(error) ?? 'connected');
|
|
62
|
+
}
|
|
63
|
+
checking = null;
|
|
64
|
+
notify();
|
|
65
|
+
return state;
|
|
66
|
+
})();
|
|
67
|
+
notify();
|
|
68
|
+
return checking;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
snapshot,
|
|
73
|
+
|
|
74
|
+
subscribe(listener) {
|
|
75
|
+
listeners.add(listener);
|
|
76
|
+
return () => listeners.delete(listener);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Every response the server sends: a successful one proves the link.
|
|
80
|
+
observe(status) {
|
|
81
|
+
if (status >= 200 && status < 300 && state !== 'connected') {
|
|
82
|
+
settle('connected');
|
|
83
|
+
notify();
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Every failed request. Returns the problem it stands for, if any; a 401 is
|
|
88
|
+
// checked before being taken for a lost login.
|
|
89
|
+
report(error) {
|
|
90
|
+
const problem = connectionProblem(error);
|
|
91
|
+
if (problem === 'offline' && state !== 'offline') {
|
|
92
|
+
state = 'offline';
|
|
93
|
+
notify();
|
|
94
|
+
} else if (problem === 'signedOut' && state !== 'signedOut') {
|
|
95
|
+
check();
|
|
96
|
+
} else if (problem === null && state !== 'connected') {
|
|
97
|
+
// Refused for a reason of its own (the plugin is still starting, say):
|
|
98
|
+
// the server answered, and past its login.
|
|
99
|
+
settle('connected');
|
|
100
|
+
notify();
|
|
101
|
+
}
|
|
102
|
+
return problem;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
check
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Looks again as soon as there is reason to think the link is back: the
|
|
110
|
+
// network returned, or the reader came back to the page — from signing in
|
|
111
|
+
// on another tab, typically.
|
|
112
|
+
export function watchConnection(connection, { window, document }) {
|
|
113
|
+
const recheck = () => {
|
|
114
|
+
if (connection.snapshot().state !== 'connected' && document.visibilityState !== 'hidden') {
|
|
115
|
+
connection.check();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
window.addEventListener('online', recheck);
|
|
119
|
+
window.addEventListener('focus', recheck);
|
|
120
|
+
document.addEventListener('visibilitychange', recheck);
|
|
121
|
+
return () => {
|
|
122
|
+
window.removeEventListener('online', recheck);
|
|
123
|
+
window.removeEventListener('focus', recheck);
|
|
124
|
+
document.removeEventListener('visibilitychange', recheck);
|
|
125
|
+
};
|
|
126
|
+
}
|
package/public/js/context.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useRef, useState } from '../vendor/preact-htm.mjs';
|
|
2
|
+
import { connection } from './api.mjs';
|
|
2
3
|
import { parseAnimationHash } from './days.mjs';
|
|
3
4
|
import { createFormatter } from './format.mjs';
|
|
4
5
|
import { createTranslator } from './i18n.mjs';
|
|
@@ -86,3 +87,13 @@ export function usePolling(load, intervalMs, dependencies = []) {
|
|
|
86
87
|
};
|
|
87
88
|
}, dependencies);
|
|
88
89
|
}
|
|
90
|
+
|
|
91
|
+
// The link to the server as the page last found it (see connection.mjs).
|
|
92
|
+
export function useConnection() {
|
|
93
|
+
const [link, setLink] = useState(connection.snapshot);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setLink(connection.snapshot());
|
|
96
|
+
return connection.subscribe(setLink);
|
|
97
|
+
}, []);
|
|
98
|
+
return link;
|
|
99
|
+
}
|
package/public/js/i18n.mjs
CHANGED
|
@@ -27,9 +27,18 @@ export const MESSAGES = {
|
|
|
27
27
|
'Your Signal K account is not allowed to do this. Sign in with an account that has the rights.',
|
|
28
28
|
'error.signIn': 'Open the Signal K admin',
|
|
29
29
|
'error.notRunning': 'The Chiplog plugin is not running. Enable it in the Signal K admin.',
|
|
30
|
-
'error.network': 'The Signal K server cannot be reached.',
|
|
31
30
|
'error.generic': 'Something went wrong: {message}',
|
|
32
31
|
|
|
32
|
+
'connection.offline':
|
|
33
|
+
'The Signal K server does not answer: the network may be down, or the server restarting.',
|
|
34
|
+
'connection.signedOut': 'Your Signal K session has ended. Sign in again, then reconnect.',
|
|
35
|
+
'connection.checking': 'Connecting to the Signal K server…',
|
|
36
|
+
'connection.reconnect': 'Reconnect',
|
|
37
|
+
'connection.stillDown': 'Still no luck.',
|
|
38
|
+
'connection.signIn': 'Sign in',
|
|
39
|
+
'connection.restored': 'The connection is back.',
|
|
40
|
+
'connection.tryAgain': 'What failed can be done again.',
|
|
41
|
+
|
|
33
42
|
'status.underwaySail': 'Under way under sail',
|
|
34
43
|
'status.underwayEngine': 'Under way under engine',
|
|
35
44
|
'status.underway': 'Under way',
|
|
@@ -517,6 +526,10 @@ export const MESSAGES = {
|
|
|
517
526
|
'entry.noteTime': 'Time (now if left empty)',
|
|
518
527
|
'entry.noPassageError': 'No passage to log this in: cast off or weigh anchor first.',
|
|
519
528
|
'entry.refused': 'Not logged: {message}',
|
|
529
|
+
'entry.notDoneOffline': 'Not done: the Signal K server does not answer.',
|
|
530
|
+
'entry.stillOffline':
|
|
531
|
+
'Still no answer from the Signal K server. Entries keep waiting on this tablet.',
|
|
532
|
+
'entry.reconnecting': 'Connecting…',
|
|
520
533
|
'entry.chooseSail': 'Which sail is up?',
|
|
521
534
|
'entry.otherSail': 'Other sail',
|
|
522
535
|
'entry.sketchUndo': 'Undo',
|
|
@@ -591,9 +604,19 @@ export const MESSAGES = {
|
|
|
591
604
|
'error.signIn': 'Ouvrir l’administration Signal K',
|
|
592
605
|
'error.notRunning':
|
|
593
606
|
'Le plugin Chiplog n’est pas démarré. Activez-le dans l’administration Signal K.',
|
|
594
|
-
'error.network': 'Le serveur Signal K est injoignable.',
|
|
595
607
|
'error.generic': 'Une erreur est survenue : {message}',
|
|
596
608
|
|
|
609
|
+
'connection.offline':
|
|
610
|
+
'Le serveur Signal K ne répond pas : le réseau est peut-être coupé, ou le serveur redémarre.',
|
|
611
|
+
'connection.signedOut':
|
|
612
|
+
'Votre session Signal K a pris fin. Connectez-vous à nouveau, puis rétablissez la connexion.',
|
|
613
|
+
'connection.checking': 'Connexion au serveur Signal K…',
|
|
614
|
+
'connection.reconnect': 'Rétablir la connexion',
|
|
615
|
+
'connection.stillDown': 'Toujours sans succès.',
|
|
616
|
+
'connection.signIn': 'Se connecter',
|
|
617
|
+
'connection.restored': 'La connexion est rétablie.',
|
|
618
|
+
'connection.tryAgain': 'Vous pouvez refaire ce qui a échoué.',
|
|
619
|
+
|
|
597
620
|
'status.underwaySail': 'En route à la voile',
|
|
598
621
|
'status.underwayEngine': 'En route au moteur',
|
|
599
622
|
'status.underway': 'En route',
|
|
@@ -1092,6 +1115,10 @@ export const MESSAGES = {
|
|
|
1092
1115
|
'entry.noPassageError':
|
|
1093
1116
|
'Aucune navigation où consigner : appareillez ou levez l’ancre d’abord.',
|
|
1094
1117
|
'entry.refused': 'Non consigné : {message}',
|
|
1118
|
+
'entry.notDoneOffline': 'Non effectué : le serveur Signal K ne répond pas.',
|
|
1119
|
+
'entry.stillOffline':
|
|
1120
|
+
'Le serveur Signal K ne répond toujours pas. Les entrées restent en attente sur cette tablette.',
|
|
1121
|
+
'entry.reconnecting': 'Connexion…',
|
|
1095
1122
|
'entry.chooseSail': 'Quelle voile est envoyée ?',
|
|
1096
1123
|
'entry.otherSail': 'Autre voile',
|
|
1097
1124
|
'entry.sketchUndo': 'Annuler',
|
package/public/js/main.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { html, render } from '../vendor/preact-htm.mjs';
|
|
2
|
+
import { connection } from './api.mjs';
|
|
3
|
+
import { watchConnection } from './connection.mjs';
|
|
2
4
|
import { createLocale, LocaleProvider, useLocale, useRoute } from './context.mjs';
|
|
3
5
|
import { pickLanguage } from './i18n.mjs';
|
|
4
6
|
import { AnimationView } from './components/AnimationView.mjs';
|
|
@@ -8,6 +10,7 @@ import { PassageView } from './components/PassageView.mjs';
|
|
|
8
10
|
import { ReplayView } from './components/ReplayView.mjs';
|
|
9
11
|
import { StatisticsView } from './components/StatisticsView.mjs';
|
|
10
12
|
import { StatusBar } from './components/StatusBar.mjs';
|
|
13
|
+
import { ConnectionToast } from './components/common.mjs';
|
|
11
14
|
|
|
12
15
|
function Page({ route }) {
|
|
13
16
|
if (route.name === 'passage') {
|
|
@@ -50,6 +53,7 @@ function Shell() {
|
|
|
50
53
|
<${StatusBar} />
|
|
51
54
|
<main><${Page} route=${route} /></main>
|
|
52
55
|
<footer class="attribution">${t('footer.attribution')}</footer>
|
|
56
|
+
<${ConnectionToast} />
|
|
53
57
|
`;
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -57,6 +61,8 @@ const locale = createLocale(pickLanguage(navigator.languages, location.search));
|
|
|
57
61
|
document.documentElement.lang = locale.language;
|
|
58
62
|
document.title = locale.t('app.title');
|
|
59
63
|
|
|
64
|
+
watchConnection(connection, { window, document });
|
|
65
|
+
|
|
60
66
|
render(
|
|
61
67
|
html`<${LocaleProvider} value=${locale}><${Shell} /></${LocaleProvider}>`,
|
|
62
68
|
document.getElementById('app')
|