sailkick-boat 0.20.0 → 0.21.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/README.md +31 -10
- package/index.js +6 -3
- package/lib/account/index.js +19 -5
- package/lib/ais/index.js +10 -1
- package/lib/backfill/index.js +5 -1
- package/lib/history/ring.js +30 -7
- package/lib/sync/index.js +39 -4
- package/lib/sync/influxWrite.js +18 -5
- package/package.json +8 -1
- package/public/icon.png +0 -0
- package/public/index.html +3 -2
package/README.md
CHANGED
|
@@ -52,8 +52,13 @@ files to send later — nothing is lost in the gap.
|
|
|
52
52
|
|
|
53
53
|
- Network errors, `429` and `5xx` are retried with backoff (1 s → 60 s); the data stays
|
|
54
54
|
on disk.
|
|
55
|
-
- A `4xx` is quarantined to `spool/dead/`
|
|
56
|
-
|
|
55
|
+
- A malformed batch (`4xx` other than the three below) is quarantined to `spool/dead/`
|
|
56
|
+
rather than retried forever, because it would otherwise wedge the queue behind it.
|
|
57
|
+
- **`404`, `401` and `403` are held, not quarantined.** A missing bucket or a rejected
|
|
58
|
+
token rejects *every* batch identically, so quarantining would pour the whole telemetry
|
|
59
|
+
stream into `spool/dead/` while the plugin looked busy. Instead the spool is left
|
|
60
|
+
intact, the status line names the setting to fix, and retries continue slowly so a
|
|
61
|
+
correction recovers on its own.
|
|
57
62
|
- The buffer is bounded (500 MB). On overflow the **oldest** files are dropped and
|
|
58
63
|
logged — a long-offline boat fills its own disk otherwise.
|
|
59
64
|
- Timestamps are nanosecond-precise, so replaying after a reconnect overwrites rather
|
|
@@ -257,8 +262,12 @@ worth designing properly rather than guessing at, so for now they are simply sep
|
|
|
257
262
|
## Local history (offline Trends + track)
|
|
258
263
|
**One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
|
|
259
264
|
— no database, works on a Victron GX with nothing else installed. `historyAvailable` is
|
|
260
|
-
forced on so the app shows the Trends panel, and the
|
|
261
|
-
serves,
|
|
265
|
+
forced on so the app shows the Trends panel, and the **nineteen channels match what the
|
|
266
|
+
cloud serves** — wind (true/apparent, speed/angle/direction), SOG/STW/COG/heading, depth,
|
|
267
|
+
VMG, sea and air temperature, port and starboard revs, and the four active-waypoint
|
|
268
|
+
values. So an instrument cell's history flyout shows the same thing whichever provider is
|
|
269
|
+
answering. A channel with no data is simply absent: a boat with no paddlewheel has no
|
|
270
|
+
`stw`, and the waypoint channels appear only while a destination is active.
|
|
262
271
|
|
|
263
272
|
There is deliberately **no way to point this at a local InfluxDB**. Until v0.15.0 a read
|
|
264
273
|
token did exactly that, and it was a trap: the app only ever asks for a *relative* window
|
|
@@ -424,12 +433,24 @@ online boat is never worse off than before.
|
|
|
424
433
|
is needed — see above). The signup screen shows your boat's ingest credentials.
|
|
425
434
|
2. **Copy the "Write token"** — it is shown **once** and cannot be recovered. If you
|
|
426
435
|
lose it you need a new one minted.
|
|
427
|
-
3. In Plugin Config, fill the **Sailkick account** section — **
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
That is the whole handshake. The plugin resolves everything else locally (`
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
3. In Plugin Config, fill the **Sailkick account** section — **write token**, plus
|
|
437
|
+
either the **boat name** or the **data bucket** — and save.
|
|
438
|
+
|
|
439
|
+
That is the whole handshake. The plugin resolves everything else locally (`org` =
|
|
440
|
+
`sailkick`) and never calls the app for configuration, so setup works with no internet
|
|
441
|
+
and there is nothing to re-fetch after a restart.
|
|
442
|
+
|
|
443
|
+
**Data bucket.** Leave it blank and the bucket is derived as `<boat name>_raw`, which is
|
|
444
|
+
what older accounts use. Newer accounts are identified by a UUID instead — their bucket
|
|
445
|
+
looks like `1fcad258-c422-4e93-a6f9-6811938499f6_raw` — so paste that into **Data
|
|
446
|
+
bucket** and the boat name becomes optional. If your bucket is ever renamed, this is the
|
|
447
|
+
one field to change: the write token survives a rename, because InfluxDB scopes tokens by
|
|
448
|
+
bucket *id* rather than by name.
|
|
449
|
+
|
|
450
|
+
A bucket that does not exist is treated as a **configuration error, not bad data**:
|
|
451
|
+
telemetry is held on disk, nothing is quarantined, the status line reads
|
|
452
|
+
`sync: HELD — bucket "…" not found`, and it sends itself as soon as the name is
|
|
453
|
+
corrected — no restart needed. The same applies to a rejected token.
|
|
433
454
|
|
|
434
455
|
> **Upgrading from before 0.14?** Old versions had visible `influxUrl` / `sailkickUrl`
|
|
435
456
|
> fields. Whatever was typed into them is still in your saved config, and since 0.14.4
|
package/index.js
CHANGED
|
@@ -121,8 +121,9 @@ module.exports = function (app) {
|
|
|
121
121
|
title: 'Sailkick account',
|
|
122
122
|
description: 'Register your boat at www.sailkick.io first — the signup screen shows a write token. Paste it here together with your boat name. Keep that token safe: it is shown only once and cannot be recovered.',
|
|
123
123
|
properties: {
|
|
124
|
-
slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
|
|
125
|
-
writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token").
|
|
124
|
+
slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website. Newer accounts are identified by a data bucket instead — if you have one, fill that in below and leave this blank.' },
|
|
125
|
+
writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). The Influx URL and organization are fleet-wide and set for you.' },
|
|
126
|
+
bucket: { type: 'string', title: 'Data bucket', description: 'Leave blank to derive it from the boat name. Set it when your account has an explicit bucket — newer ones are a UUID, e.g. 1fcad258-c422-4e93-a6f9-6811938499f6_raw. Copy it exactly; a bucket that does not exist stops sync rather than losing data, and the status line will say so.' }
|
|
126
127
|
}
|
|
127
128
|
},
|
|
128
129
|
sync: {
|
|
@@ -191,7 +192,9 @@ module.exports = function (app) {
|
|
|
191
192
|
// Purely local: the boat registers on the website, the owner pastes the write
|
|
192
193
|
// token here. Nothing to fetch, so nothing that can fail while offline.
|
|
193
194
|
const r = resolveAccountConfig(app, opts.account)
|
|
194
|
-
|
|
195
|
+
// Name the BUCKET, not just the boat: a bucket that no longer exists (a rename, a
|
|
196
|
+
// typo) is the one misconfiguration that stops sync dead, and it was invisible here.
|
|
197
|
+
if (r.bundle) accountStatus = `account: ${r.bundle.slug || '(no name)'} -> ${r.bundle.bucket}`
|
|
195
198
|
else if (r.error) accountStatus = `account: ${r.error}`
|
|
196
199
|
else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
|
|
197
200
|
startModules(opts, r.bundle)
|
package/lib/account/index.js
CHANGED
|
@@ -8,10 +8,19 @@
|
|
|
8
8
|
// is the whole handshake. The plugin deliberately does NOT sign up on its own —
|
|
9
9
|
// one registration path means no half-created accounts and no spent-invite dead ends.
|
|
10
10
|
//
|
|
11
|
-
// Everything else about the bundle
|
|
11
|
+
// Everything else about the bundle WAS derivable, so only two fields used to be asked for:
|
|
12
12
|
// bucket = "<slug>_raw" (the app's convention — server/auth/registry.js bucketFor)
|
|
13
13
|
// org = "sailkick" (single org, fleet-wide)
|
|
14
14
|
//
|
|
15
|
+
// That derivation is no longer reliable. The fleet is moving boat identity from a slug to
|
|
16
|
+
// a UUID, and buckets are being renamed with it — this boat went from "addiction_raw" to
|
|
17
|
+
// "1fcad258-c422-4e93-a6f9-6811938499f6_raw" while keeping the same token, because
|
|
18
|
+
// InfluxDB scopes tokens by bucket ID and a rename preserves it. So `bucket` is now an
|
|
19
|
+
// explicit field: blank keeps the old derivation (every pre-existing install is
|
|
20
|
+
// untouched), set wins verbatim. The bucket is the actual contract with InfluxDB, so it
|
|
21
|
+
// is the thing worth being able to state directly — it survives whatever identity the
|
|
22
|
+
// fleet settles on.
|
|
23
|
+
//
|
|
15
24
|
// This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
|
|
16
25
|
// cloud" are separate questions, and the second one has a single fleet-wide answer that
|
|
17
26
|
// index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
|
|
@@ -56,15 +65,20 @@ const clean = (v) => String(v == null ? '' : v).trim()
|
|
|
56
65
|
function resolveAccountConfig (app, account) {
|
|
57
66
|
const acct = account || {}
|
|
58
67
|
const slug = clean(acct.slug).toLowerCase()
|
|
68
|
+
// NOT lowercased: a bucket name is taken verbatim from whatever the server called it.
|
|
69
|
+
const bucket = clean(acct.bucket)
|
|
59
70
|
const writeToken = clean(acct.writeToken)
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
// Either identifier will do. A boat given an explicit bucket needs no slug at all —
|
|
73
|
+
// newer accounts are issued a UUID bucket and may never see a slug — while an older
|
|
74
|
+
// install that only has a boat name keeps deriving as before.
|
|
75
|
+
if (writeToken && (slug || bucket)) {
|
|
62
76
|
return {
|
|
63
77
|
source: 'config',
|
|
64
78
|
bundle: {
|
|
65
79
|
slug,
|
|
66
80
|
org: clean(acct.org) || DEFAULT_ORG,
|
|
67
|
-
bucket:
|
|
81
|
+
bucket: bucket || `${slug}_raw`,
|
|
68
82
|
writeToken
|
|
69
83
|
}
|
|
70
84
|
}
|
|
@@ -74,8 +88,8 @@ function resolveAccountConfig (app, account) {
|
|
|
74
88
|
if (cached) return { bundle: cached, source: 'cache' }
|
|
75
89
|
|
|
76
90
|
// Half-filled: say which half, so the status line is actionable.
|
|
77
|
-
if (writeToken) return { bundle: null, source: null, error: 'boat name is missing' }
|
|
78
|
-
if (slug) return { bundle: null, source: null, error: 'write token is missing' }
|
|
91
|
+
if (writeToken) return { bundle: null, source: null, error: 'boat name or data bucket is missing' }
|
|
92
|
+
if (slug || bucket) return { bundle: null, source: null, error: 'write token is missing' }
|
|
79
93
|
return { bundle: null, source: null }
|
|
80
94
|
}
|
|
81
95
|
|
package/lib/ais/index.js
CHANGED
|
@@ -205,7 +205,12 @@ function createAis (app, options) {
|
|
|
205
205
|
if (!body.trim()) { await state.spool.remove(file); continue }
|
|
206
206
|
const res = await writeLines({ influxUrl: cfg.influxUrl, org: cfg.org, bucket: cfg.bucket, token: cfg.token, timeoutMs: cfg.requestTimeoutMs }, body)
|
|
207
207
|
if (res.ok) {
|
|
208
|
-
await state.spool.remove(file); state.lastOkAt = new Date(); state.backoff = cfg.retryMinMs
|
|
208
|
+
await state.spool.remove(file); state.lastOkAt = new Date(); state.backoff = cfg.retryMinMs; state.configError = null
|
|
209
|
+
} else if (res.configError) {
|
|
210
|
+
// Same rule as telemetry: a missing bucket or bad token rejects everything, so
|
|
211
|
+
// hold rather than quarantine and let a corrected config drain the spool.
|
|
212
|
+
state.configError = res.status === 404 ? `bucket "${cfg.bucket}" not found` : `token rejected (HTTP ${res.status})`
|
|
213
|
+
state.pumping = false; scheduleRetry(res); return
|
|
209
214
|
} else if (res.retryable) {
|
|
210
215
|
state.pumping = false; scheduleRetry(res); return
|
|
211
216
|
} else {
|
|
@@ -238,6 +243,10 @@ function createAis (app, options) {
|
|
|
238
243
|
}
|
|
239
244
|
function refreshStatus (suffix) {
|
|
240
245
|
if (!state) return
|
|
246
|
+
if (state.configError) {
|
|
247
|
+
state.statusLine = `ais: HELD — ${state.configError}; ${state.forwarded} point(s) on disk`
|
|
248
|
+
return
|
|
249
|
+
}
|
|
241
250
|
state.statusLine = `ais: ${state.targets.size} target(s), ${state.forwarded} point(s)${state.dropped ? `, ${state.dropped} skipped` : ''}${suffix ? '; ' + suffix : ''}`
|
|
242
251
|
}
|
|
243
252
|
|
package/lib/backfill/index.js
CHANGED
|
@@ -227,7 +227,11 @@ function createBackfill (app, options) {
|
|
|
227
227
|
const res = await writeLines({ influxUrl: cfg.dst.url, org: cfg.dst.org, bucket: cfg.dst.bucket, token: cfg.dst.token, timeoutMs: cfg.queryTimeoutMs }, body)
|
|
228
228
|
if (!res.ok) {
|
|
229
229
|
// 4xx is fatal: bad credentials or malformed data, and retrying cannot fix it.
|
|
230
|
-
if (!res.retryable) {
|
|
230
|
+
if (!res.retryable) {
|
|
231
|
+
// Name the destination: after a bucket rename this is the first thing to check.
|
|
232
|
+
warn(`write to ${cfg.dst.bucket} REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`)
|
|
233
|
+
return 'fatal'
|
|
234
|
+
}
|
|
231
235
|
warn(`write ${iso(startMs)} failed — ${res.status ? 'HTTP ' + res.status : 'unreachable'}; will retry`)
|
|
232
236
|
return 'retry'
|
|
233
237
|
}
|
package/lib/history/ring.js
CHANGED
|
@@ -21,6 +21,23 @@ const fsp = fs.promises
|
|
|
21
21
|
const path = require('path')
|
|
22
22
|
|
|
23
23
|
const DEG = Math.PI / 180
|
|
24
|
+
|
|
25
|
+
// The channel set the Trends panel and the instrument history flyouts expect. It must
|
|
26
|
+
// match the cloud's provider (sailkick server/history/influx-provider.js MAP + the
|
|
27
|
+
// three active-waypoint prefixes), so the same cell shows the same history whether the
|
|
28
|
+
// boat or the cloud is answering.
|
|
29
|
+
//
|
|
30
|
+
// This drifted once already: it was frozen at the eight channels that existed before
|
|
31
|
+
// v0.18.6, while the app grew cells for true wind angle, VMG, temperatures, engine revs
|
|
32
|
+
// and the four waypoint values — and `cog` was sampled into the ring but left out of the
|
|
33
|
+
// output entirely. Eleven of nineteen cells had a live number and an empty flyout.
|
|
34
|
+
// test/history-ring.test.js pins this list against the app's own channel names.
|
|
35
|
+
const CHANNELS = [
|
|
36
|
+
'tws', 'twd', 'aws', 'awa', 'twa', 'vmg',
|
|
37
|
+
'sog', 'stw', 'cog', 'heading', 'depth',
|
|
38
|
+
'seaTemp', 'airTemp', 'rpmPort', 'rpmStbd',
|
|
39
|
+
'wptBrg', 'wptDist', 'wptVmg', 'wptTtg'
|
|
40
|
+
]
|
|
24
41
|
const wrap360 = (d) => ((d % 360) + 360) % 360
|
|
25
42
|
const MAX_SAMPLES = 50000
|
|
26
43
|
|
|
@@ -51,7 +68,7 @@ function trueWind (s) {
|
|
|
51
68
|
class RingHistoryProvider {
|
|
52
69
|
constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
|
|
53
70
|
this._source = source
|
|
54
|
-
this._ring = [] // [{ t,
|
|
71
|
+
this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
|
|
55
72
|
const win = windowSec || 3600
|
|
56
73
|
this._windowMs = win * 1000
|
|
57
74
|
this._persistFile = persistFile || null
|
|
@@ -80,6 +97,16 @@ class RingHistoryProvider {
|
|
|
80
97
|
stw: num(s.stwKt),
|
|
81
98
|
aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
|
|
82
99
|
tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
|
|
100
|
+
// Everything below arrived in BoatState with v0.18.6 but was never sampled here,
|
|
101
|
+
// so these instrument cells had a live value and an empty history flyout.
|
|
102
|
+
twa: num(s.twaDeg), vmg: num(s.vmgKt),
|
|
103
|
+
seaTemp: num(s.seaTempC), airTemp: num(s.airTempC),
|
|
104
|
+
rpmPort: num(s.rpmPort), rpmStbd: num(s.rpmStbd),
|
|
105
|
+
// Waypoint channels are legitimately null when no destination is active — num()
|
|
106
|
+
// maps that to null and CHANNELS omits empty ones, so the flyout stays blank
|
|
107
|
+
// rather than showing a flat line at zero.
|
|
108
|
+
wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
|
|
109
|
+
wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
|
|
83
110
|
lat: num(s.lat), lon: num(s.lon)
|
|
84
111
|
}
|
|
85
112
|
this._ring.push(row)
|
|
@@ -97,12 +124,8 @@ class RingHistoryProvider {
|
|
|
97
124
|
|
|
98
125
|
getSeries ({ windowSec } = {}) {
|
|
99
126
|
const rows = this._window(windowSec)
|
|
100
|
-
// Matches the InfluxDB provider's channel set exactly (lib/history/index.js MAP), so
|
|
101
|
-
// the Trends panel looks the same whichever provider is serving. Empty channels are
|
|
102
|
-
// omitted, so a boat with no paddlewheel simply has no `stw` key.
|
|
103
|
-
const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'stw', 'heading', 'depth']
|
|
104
127
|
const series = {}
|
|
105
|
-
for (const c of
|
|
128
|
+
for (const c of CHANNELS) {
|
|
106
129
|
const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
|
|
107
130
|
if (pts.length) series[c] = pts
|
|
108
131
|
}
|
|
@@ -179,4 +202,4 @@ class RingHistoryProvider {
|
|
|
179
202
|
}
|
|
180
203
|
}
|
|
181
204
|
|
|
182
|
-
module.exports = { RingHistoryProvider, MAX_SAMPLES }
|
|
205
|
+
module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS }
|
package/lib/sync/index.js
CHANGED
|
@@ -66,6 +66,27 @@ function createSync (app, options) {
|
|
|
66
66
|
if (state.failing) warn(`recovered — writing to ${cfg.influxUrl} again after ${state.failCount} failed attempt(s)`)
|
|
67
67
|
state.failing = false
|
|
68
68
|
state.failCount = 0
|
|
69
|
+
state.configError = null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A wrong bucket or token rejects every batch identically, so this is a standing
|
|
73
|
+
// condition rather than an outage: name the exact setting to fix, once, and keep the
|
|
74
|
+
// reason on the status line until it clears.
|
|
75
|
+
const noteConfigError = (res) => {
|
|
76
|
+
state.failCount++
|
|
77
|
+
const notFound = res.status === 404
|
|
78
|
+
state.configError = notFound
|
|
79
|
+
? `bucket "${cfg.bucket}" not found`
|
|
80
|
+
: `write token rejected (HTTP ${res.status})`
|
|
81
|
+
const now = Date.now()
|
|
82
|
+
if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
|
|
83
|
+
state.failing = true
|
|
84
|
+
state.lastWarnAt = now
|
|
85
|
+
warn(`sync HELD — ${state.configError}. ` +
|
|
86
|
+
(notFound
|
|
87
|
+
? 'Set "Data bucket" in the plugin config to the bucket your account actually uses — newer accounts use a UUID. '
|
|
88
|
+
: 'Check the write token, and that it is valid for this bucket. ') +
|
|
89
|
+
'Telemetry is kept on disk (nothing quarantined) and will send itself once this is corrected.')
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
const handleDelta = (delta) => {
|
|
@@ -100,13 +121,21 @@ function createSync (app, options) {
|
|
|
100
121
|
if (res.ok) {
|
|
101
122
|
await spool.remove(file); state.lastOkAt = new Date(); state.backoff = state.retryMin
|
|
102
123
|
noteSuccess()
|
|
124
|
+
} else if (res.configError) {
|
|
125
|
+
// The settings are wrong, not this batch — every batch would be rejected the
|
|
126
|
+
// same way. Quarantining here would pour the entire telemetry stream into
|
|
127
|
+
// spool/dead/ while the plugin looked busy. Hold everything on disk, say so
|
|
128
|
+
// once, and keep retrying slowly so a corrected bucket or token recovers
|
|
129
|
+
// without a restart.
|
|
130
|
+
noteConfigError(res)
|
|
131
|
+
state.pumping = false; scheduleRetry(res); return
|
|
103
132
|
} else if (res.retryable) {
|
|
104
133
|
noteFailure(res)
|
|
105
134
|
state.pumping = false; scheduleRetry(res); return
|
|
106
135
|
} else {
|
|
107
|
-
// 4xx:
|
|
108
|
-
//
|
|
109
|
-
warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} —
|
|
136
|
+
// Other 4xx: malformed data. Never retried, so if this is not loud the batch
|
|
137
|
+
// is simply gone.
|
|
138
|
+
warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} — check the data`)
|
|
110
139
|
await spool.quarantine(file)
|
|
111
140
|
}
|
|
112
141
|
}
|
|
@@ -133,7 +162,13 @@ function createSync (app, options) {
|
|
|
133
162
|
try {
|
|
134
163
|
const { count, bytes } = await spool.stats()
|
|
135
164
|
const last = state.lastOkAt ? state.lastOkAt.toISOString() : 'never'
|
|
136
|
-
|
|
165
|
+
// A config error is the headline — it needs the owner, not just time.
|
|
166
|
+
const head = state.configError
|
|
167
|
+
? `sync: HELD — ${state.configError}; fix the plugin config`
|
|
168
|
+
: `sync: buffered ${count}f/${Math.round(bytes / 1024)}KB; last ok ${last}`
|
|
169
|
+
state.statusLine = state.configError
|
|
170
|
+
? `${head}; ${count}f/${Math.round(bytes / 1024)}KB held on disk`
|
|
171
|
+
: `${head}${suffix ? '; ' + suffix : ''}`
|
|
137
172
|
} catch {}
|
|
138
173
|
}
|
|
139
174
|
|
package/lib/sync/influxWrite.js
CHANGED
|
@@ -4,12 +4,22 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Return shape:
|
|
6
6
|
// { ok: true, status: 204 }
|
|
7
|
-
// { ok: false, retryable: true, networkError: true }
|
|
8
|
-
// { ok: false, retryable: true, status }
|
|
9
|
-
// { ok: false,
|
|
7
|
+
// { ok: false, retryable: true, networkError: true } connection failed
|
|
8
|
+
// { ok: false, retryable: true, status } 429 / 5xx (transient)
|
|
9
|
+
// { ok: false, configError: true, status, body } 404 / 401 / 403
|
|
10
|
+
// { ok: false, retryable: false, status, body } other 4xx (bad data)
|
|
10
11
|
//
|
|
11
|
-
// 4xx is non-retryable: retrying a malformed
|
|
12
|
-
//
|
|
12
|
+
// Most 4xx is non-retryable: retrying a malformed batch forever would wedge the queue,
|
|
13
|
+
// so the caller quarantines it instead.
|
|
14
|
+
//
|
|
15
|
+
// 404/401/403 are different in kind and must NOT be quarantined. They say "your settings
|
|
16
|
+
// are wrong", not "this batch is bad" — a missing bucket or a bad token rejects EVERY
|
|
17
|
+
// batch equally, so quarantining would feed the whole telemetry stream into spool/dead/
|
|
18
|
+
// while looking like normal operation. That is not hypothetical: the fleet renamed this
|
|
19
|
+
// boat's bucket from `addiction_raw` to a UUID, and a plugin left running through it
|
|
20
|
+
// would have shredded every point it collected. The caller holds these on disk instead
|
|
21
|
+
// and keeps retrying slowly, so correcting the config recovers on its own.
|
|
22
|
+
const CONFIG_ERROR_STATUS = new Set([401, 403, 404])
|
|
13
23
|
|
|
14
24
|
const zlib = require('zlib')
|
|
15
25
|
|
|
@@ -42,6 +52,9 @@ async function writeLines (cfg, body) {
|
|
|
42
52
|
|
|
43
53
|
let text = ''
|
|
44
54
|
try { text = await res.text() } catch {}
|
|
55
|
+
if (CONFIG_ERROR_STATUS.has(res.status)) {
|
|
56
|
+
return { ok: false, configError: true, retryable: false, status: res.status, body: text }
|
|
57
|
+
}
|
|
45
58
|
const retryable = res.status === 429 || res.status >= 500
|
|
46
59
|
return { ok: false, retryable, status: res.status, body: text }
|
|
47
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"keywords": [
|
|
10
10
|
"signalk-node-server-plugin",
|
|
11
11
|
"signalk-webapp",
|
|
12
|
+
"signalk-category-chart-plotters",
|
|
13
|
+
"signalk-category-instruments",
|
|
14
|
+
"signalk-category-ais",
|
|
12
15
|
"signalk",
|
|
13
16
|
"sailkick",
|
|
14
17
|
"influxdb",
|
|
@@ -17,6 +20,10 @@
|
|
|
17
20
|
"proxy"
|
|
18
21
|
],
|
|
19
22
|
"signalk-plugin-enabled-by-default": true,
|
|
23
|
+
"signalk": {
|
|
24
|
+
"appIcon": "./icon.png",
|
|
25
|
+
"displayName": "Sailkick"
|
|
26
|
+
},
|
|
20
27
|
"author": "lauchat",
|
|
21
28
|
"license": "MIT",
|
|
22
29
|
"repository": {
|
package/public/icon.png
ADDED
|
Binary file
|
package/public/index.html
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
body { margin:0; padding:2rem 1rem; background:var(--bg); color:var(--fg);
|
|
28
28
|
font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
|
|
29
29
|
main { max-width:44rem; margin:0 auto }
|
|
30
|
-
h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em }
|
|
30
|
+
h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem }
|
|
31
|
+
h1 img { border-radius:.35rem }
|
|
31
32
|
.sub { color:var(--mut); margin:0 0 1.5rem }
|
|
32
33
|
.grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)) }
|
|
33
34
|
a.card { display:block; padding:1.1rem 1.2rem; background:var(--card); border:1px solid var(--line);
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
</head>
|
|
45
46
|
<body>
|
|
46
47
|
<main>
|
|
47
|
-
<h1>Sailkick</h1>
|
|
48
|
+
<h1><img src="icon.png" alt="" width="28" height="28">Sailkick</h1>
|
|
48
49
|
<p class="sub">Charts, instruments and AIS, served from this boat.</p>
|
|
49
50
|
|
|
50
51
|
<div class="grid">
|