sailkick-boat 0.14.1 → 0.14.4

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 CHANGED
@@ -7,8 +7,8 @@
7
7
  > and a **local proxy that keeps the sailkick app and its charts/maps fully usable
8
8
  > offline** on board. The service runs at **[www.sailkick.io](https://www.sailkick.io)**.
9
9
  >
10
- > **Pairing needs an invite code** there is no open registration yet, so installing
11
- > this plugin from the Signal K Appstore is not enough on its own. Expect breaking
10
+ > **Registration is invite-only** and happens on the website, not in the plugin — so
11
+ > installing this from the Signal K Appstore is not enough on its own. Expect breaking
12
12
  > changes while the version is 0.x.
13
13
  >
14
14
  > **Want an invite, or more information?** Get in touch:
@@ -159,22 +159,42 @@ works **offline** with the boat's own data. Only when neither a local InfluxDB n
159
159
  telemetry is available do these paths **fall through to the cloud mirror**, so an
160
160
  online boat is never worse off than before.
161
161
 
162
- ## Setup: pair the boat once
163
- Fill the **Sailkick account** section — **invite code + boat name + password** — and
164
- save. The plugin calls the app's `POST /api/auth/signup` once: that creates your
165
- account, provisions its cloud storage, and returns the write token, bucket, org and
166
- InfluxDB URL for your boat. The bundle is cached (0600) in the plugin data dir and
167
- reused on every later start, so sync keeps working **offline** and across restarts.
162
+ ## Setup: register on the web, then paste the token
163
+ 1. Register your boat at **[www.sailkick.io](https://www.sailkick.io)** (an invite code
164
+ is needed — see above). The signup screen shows your boat's ingest credentials.
165
+ 2. **Copy the "Write token"** it is shown **once** and cannot be recovered. If you
166
+ lose it you need a new one minted.
167
+ 3. In Plugin Config, fill the **Sailkick account** section — **boat name + write
168
+ token** — and save.
168
169
 
169
- Pairing is **one-time by design** the invite code is single-use, so the plugin never
170
- calls signup twice. Once `account.json` exists the account fields are no longer read;
171
- delete that file to re-pair (which needs a fresh invite).
170
+ That is the whole handshake. The plugin resolves everything else locally (`bucket` =
171
+ `<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
172
+ works with no internet and there is nothing to re-fetch after a restart.
173
+
174
+ > **Upgrading from before 0.14?** Old versions had visible `influxUrl` / `sailkickUrl`
175
+ > fields. Whatever was typed into them is still in your saved config, and since 0.14.4
176
+ > those leftovers are **ignored** — the plugin says so in the log and the status line
177
+ > rather than silently obeying a dev address. To keep your own endpoints deliberately,
178
+ > set `sync.selfHosted: true` / `proxy.selfHosted: true` in the config JSON.
179
+ >
180
+ > **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
181
+ > the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
182
+ > `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
183
+ > so a wrong value can never redirect your telemetry. (Self-hosters: hand-edit
184
+ > `sync.influxUrl` in the plugin config JSON.) If the status line ever warns that sync is
185
+ > writing to a local address, telemetry is not leaving the boat.
186
+
187
+ Registration deliberately happens **only** in the web app. The plugin cannot sign up,
188
+ so there is no way to half-create an account or burn an invite from the boat.
189
+
190
+ > Changing the token later — after a rotation, say — is just editing the field and
191
+ > saving. Pasted values are trimmed, and the boat name is lower-cased for you.
172
192
 
173
193
  ## Config
174
194
  The page is deliberately small — everything else has a right answer and is a constant
175
195
  in `index.js`.
176
196
 
177
- - **Sailkick account**: `invite`, `slug` (boat name), `password`
197
+ - **Sailkick account**: `slug` (boat name), `writeToken`
178
198
  - **Telemetry sync → cloud**: `enabled`
179
199
  - **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
180
200
  (default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
@@ -217,6 +237,32 @@ a VE.Can↔Micro-C drop cable; leave the spare RJ45 unterminated) or a USB GPS/N
217
237
  gateway. Without a position source, the live boat / trends / area-download stay
218
238
  idle — energy telemetry still syncs to the cloud.
219
239
 
240
+ ## Troubleshooting: is telemetry actually leaving the boat?
241
+ The plugin logs one unconditional line at startup naming its real target:
242
+ ```
243
+ [sailkick-boat] sync -> https://sync.sailkick.io org=sailkick bucket=<slug>_raw
244
+ ```
245
+ If that shows anything other than the endpoint you expect, a stale config field is the
246
+ usual cause — see the upgrade note above.
247
+
248
+ Failures reach the **normal server log**, not just the debug channel:
249
+ - `cannot write to <url> — unreachable …; N failed attempt(s), telemetry is buffering
250
+ on disk` — once per outage, then every 5 min while it lasts, then a `recovered` line.
251
+ - `batch REJECTED (HTTP 401) and quarantined …` — the write token is not valid for that
252
+ bucket. These are never retried, so they are always logged.
253
+ - `sync: ⚠ writing to <url> — a private address` — a loopback/RFC1918 target, i.e.
254
+ nothing is reaching the cloud.
255
+
256
+ Nothing at all in the log means the plugin never started; check it is enabled. The
257
+ status line in Plugin Config carries the same information continuously.
258
+
259
+ To prove credentials independently of the plugin:
260
+ ```bash
261
+ curl -i -XPOST "https://sync.sailkick.io/api/v2/write?org=sailkick&bucket=<slug>_raw&precision=ns" \
262
+ -H "Authorization: Token $TOKEN" --data-binary "probe,context=vessels.self,self=true,source=manual value=1"
263
+ ```
264
+ `204` = good, `401` = token not valid for that bucket.
265
+
220
266
  ## Dev / tests
221
267
  ```bash
222
268
  npm install && npm test # proxy: mirror/cache/offline + Express route; sync: subscribe+buffer
package/index.js CHANGED
@@ -14,10 +14,18 @@ const { resolveAccountConfig } = require('./lib/account')
14
14
  // Kept as separate modules so a proxy fault can't wedge the data-critical sync.
15
15
 
16
16
  // Fixed cloud endpoints. Boats never choose these, so they are constants rather than
17
- // config fields but every module still reads its option first (see startModules), so
18
- // a hand-edited plugin-config JSON can override any of them for self-hosting.
17
+ // config fields, and NOTHING in a saved config may override them unless `selfHosted` is
18
+ // explicitly set (see resolveEndpoint).
19
+ //
20
+ // v0.14.3 kept `sync.influxUrl` / `proxy.sailkickUrl` as a silent escape hatch, on the
21
+ // reasoning that a value could only get there by deliberate hand-editing. That was
22
+ // wrong: both were VISIBLE FIELDS before v0.14.0, so every upgraded install still has
23
+ // whatever was typed into them — and since they are no longer in the schema, the owner
24
+ // can neither see nor clear them from the UI. One boat spent a day POSTing valid cloud
25
+ // credentials at a dev LAN address for exactly this reason. Stale wins over intent, so
26
+ // leftovers are now ignored and reported rather than obeyed.
19
27
  const SAILKICK_APP_URL = 'https://www.sailkick.io' // signup + mirror upstream
20
- const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress; pairing overrides this
28
+ const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress
21
29
 
22
30
  // Tuning that has a right answer. Deliberately NOT on the config page: a boat owner has
23
31
  // no basis to choose these, and a wrong value silently degrades sync or the cache.
@@ -45,6 +53,32 @@ const HISTORY_TUNING = {
45
53
  ringSampleSec: 15
46
54
  }
47
55
 
56
+ // A *cloud* endpoint on a loopback or private address means telemetry never leaves the
57
+ // LAN. On the wire that is indistinguishable from a normal offline backlog — the spool
58
+ // just grows — so it has to be called out explicitly. Covers RFC1918 as well as
59
+ // loopback: the case that actually bit was a 192.168.x dev box, which a loopback-only
60
+ // check sailed straight past. (The local history DB is legitimately on 127.0.0.1; this
61
+ // is only ever applied to the sync path.)
62
+ function isPrivateHostUrl (u) {
63
+ try {
64
+ const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
65
+ if (h === 'localhost' || h === '::1' || /^127\./.test(h)) return true
66
+ if (/^10\./.test(h) || /^192\.168\./.test(h)) return true
67
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
68
+ return false
69
+ } catch { return false }
70
+ }
71
+
72
+ // Endpoint resolution with one rule: the constant wins unless self-hosting is declared.
73
+ // Returns { url, ignored } — `ignored` is the stale value we refused, so the caller can
74
+ // say so out loud instead of leaving the owner to guess why nothing arrives.
75
+ function resolveEndpoint (configured, constant, selfHosted) {
76
+ const v = typeof configured === 'string' ? configured.trim() : ''
77
+ if (!v || v === constant) return { url: constant, ignored: null }
78
+ if (selfHosted) return { url: v, ignored: null }
79
+ return { url: constant, ignored: v }
80
+ }
81
+
48
82
  module.exports = function (app) {
49
83
  let sync = null
50
84
  let proxy = null
@@ -52,6 +86,7 @@ module.exports = function (app) {
52
86
  let history = null
53
87
  let statusTimer = null
54
88
  let accountStatus = null
89
+ let syncWarning = null
55
90
 
56
91
  const plugin = {
57
92
  id: 'sailkick-boat',
@@ -66,12 +101,11 @@ module.exports = function (app) {
66
101
  properties: {
67
102
  account: {
68
103
  type: 'object',
69
- title: 'Sailkick account (one-time pairing)',
70
- description: 'Pair this boat with your sailkick account once. The plugin creates the account, provisions its cloud storage, and stores the result locally — so it keeps syncing offline and after restarts. Your invite code is single-use; once paired these fields are no longer read.',
104
+ title: 'Sailkick account',
105
+ 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.',
71
106
  properties: {
72
- invite: { type: 'string', title: 'Invite code' },
73
- slug: { type: 'string', title: 'Boat name', description: '2–40 characters: lowercase letters, numbers, dashes. This is your permanent boat id.' },
74
- password: { type: 'string', title: 'Password', description: 'At least 8 characters.' }
107
+ slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
108
+ writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). Everything else — Influx URL, organization, bucket is derived from it and your boat name.' }
75
109
  }
76
110
  },
77
111
  sync: {
@@ -115,23 +149,17 @@ module.exports = function (app) {
115
149
 
116
150
  plugin.start = function (options) {
117
151
  const opts = options || {}
118
- // Pairing first: on the very first start this provisions the account and caches the
119
- // bundle; on every later start it is a disk read (see lib/account — signup is
120
- // single-use and must never run twice).
121
- ;(async () => {
122
- const r = await resolveAccountConfig(app, opts.account, { appUrl: SAILKICK_APP_URL })
123
- if (r.bundle) {
124
- accountStatus = r.source === 'paired'
125
- ? `account: paired as ${r.bundle.slug}`
126
- : `account: ${r.bundle.slug}`
127
- } else if (r.error) {
128
- accountStatus = `account: pairing failed — ${r.error}${r.terminal ? '' : ' (will retry on restart)'}`
129
- ;(app.error || console.error)('[sailkick-boat] pairing failed: ' + r.error)
130
- } else {
131
- accountStatus = 'account: not paired — enter your invite code'
132
- }
152
+ try {
153
+ // Purely local: the boat registers on the website, the owner pastes the write
154
+ // token here. Nothing to fetch, so nothing that can fail while offline.
155
+ const r = resolveAccountConfig(app, opts.account)
156
+ if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
157
+ else if (r.error) accountStatus = `account: ${r.error}`
158
+ else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
133
159
  startModules(opts, r.bundle)
134
- })().catch((e) => (app.error || console.error)('[sailkick-boat] start failed: ' + e.message))
160
+ } catch (e) {
161
+ (app.error || console.error)('[sailkick-boat] start failed: ' + e.message)
162
+ }
135
163
  }
136
164
 
137
165
  function startModules (opts, bundle) {
@@ -146,12 +174,17 @@ module.exports = function (app) {
146
174
  const store = p.dataDir || p.storeDir || undefined
147
175
  const spoolDir = s.spoolDir || (p.dataDir ? path.join(p.dataDir, 'spool') : undefined)
148
176
 
177
+ // Self-hosting is opt-in and explicit. Without it, a saved endpoint is treated as a
178
+ // leftover from an older version rather than as intent.
179
+ const selfHosted = s.selfHosted === true || p.selfHosted === true
180
+ const influx = resolveEndpoint(s.influxUrl, SAILKICK_INFLUX_URL, selfHosted)
181
+ const upstream = resolveEndpoint(p.sailkickUrl, SAILKICK_APP_URL, selfHosted)
182
+
149
183
  // --- sync module (isolated: its failure must not affect the proxy) ---
150
- // Pairing wins over hand-written fields; hand-written fields win over the constants.
151
184
  if (s.enabled !== false) {
152
185
  const syncOpts = {
153
186
  ...SYNC_TUNING,
154
- influxUrl: b.influxUrl || s.influxUrl || SAILKICK_INFLUX_URL,
187
+ influxUrl: influx.url,
155
188
  org: b.org || s.org || SYNC_TUNING.org,
156
189
  bucket: b.bucket || s.bucket,
157
190
  token: b.writeToken || s.token,
@@ -164,6 +197,18 @@ module.exports = function (app) {
164
197
  retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
165
198
  retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
166
199
  }
200
+ // One unconditional line naming the actual target. This is the single most useful
201
+ // thing in the log: it would have shown the stale 192.168.x address instantly,
202
+ // instead of a day spent inferring it from an absence of errors.
203
+ console.log(`[sailkick-boat] sync -> ${syncOpts.influxUrl} org=${syncOpts.org} bucket=${syncOpts.bucket || '(none)'}${selfHosted ? ' [self-hosted]' : ''}`)
204
+
205
+ if (influx.ignored) {
206
+ syncWarning = `sync: ignoring stale influxUrl ${influx.ignored} — using ${influx.url}`
207
+ ;(app.error || console.error)(`[sailkick-boat] ignoring sync.influxUrl "${influx.ignored}" left over from an older config — using ${influx.url}. Set sync.selfHosted:true to keep your own endpoint.`)
208
+ } else if (isPrivateHostUrl(syncOpts.influxUrl)) {
209
+ syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — a private address, telemetry is NOT reaching the cloud`
210
+ ;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a loopback/private address — telemetry will not reach the cloud`)
211
+ }
167
212
  try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
168
213
  (app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
169
214
  sync = null
@@ -175,8 +220,11 @@ module.exports = function (app) {
175
220
  const oldSeed = p.seed || {}
176
221
  const oldPrefetch = p.prefetch || {}
177
222
  const oldHistory = p.history || {}
223
+ if (upstream.ignored) {
224
+ ;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
225
+ }
178
226
  const pOpts = {
179
- sailkickUrl: p.sailkickUrl || SAILKICK_APP_URL,
227
+ sailkickUrl: upstream.url,
180
228
  storeDir: store,
181
229
  proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
182
230
  localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
@@ -255,6 +303,7 @@ module.exports = function (app) {
255
303
  if (!app.setPluginStatus) return
256
304
  const parts = []
257
305
  if (accountStatus) parts.push(accountStatus)
306
+ if (syncWarning) parts.push(syncWarning)
258
307
  if (sync) parts.push(sync.status())
259
308
  if (proxy) parts.push(proxy.status())
260
309
  if (telemetry) parts.push(telemetry.status())
@@ -274,6 +323,7 @@ module.exports = function (app) {
274
323
  history = null
275
324
  proxy = null
276
325
  accountStatus = null
326
+ syncWarning = null
277
327
  }
278
328
 
279
329
  // Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
@@ -1,113 +1,87 @@
1
1
  'use strict'
2
2
 
3
- // Sailkick account pairingONE TIME, then cached forever.
3
+ // Sailkick account configa pure local resolver, no network.
4
4
  //
5
- // The owner enters an invite code + boat name + password once. The plugin POSTs them to
6
- // the app's /api/auth/signup, which provisions the boat's InfluxDB bucket + write token
7
- // and returns an `ingest` bundle. That bundle is cached (0600) in the plugin data dir
8
- // and reused on every subsequent start.
5
+ // Registration happens in the web app: the user redeems an invite there, and the
6
+ // signup screen shows the boat's ingest credentials once (see the app's
7
+ // public/ui/login-overlay.js). They paste the write token into this plugin, and that
8
+ // is the whole handshake. The plugin deliberately does NOT sign up on its own
9
+ // one registration path means no half-created accounts and no spent-invite dead ends.
9
10
  //
10
- // Cache-first is NOT an optimisation here, it is correctness: signup consumes a
11
- // single-use invite and creates the account, so calling it twice returns 403
12
- // bad-invite / 409 slug-taken. The cached bundle is the only repeatable path —
13
- // /auth/login issues a session cookie but no token, and the app never persists the
14
- // write token, so there is nothing to re-fetch.
11
+ // Everything else about the bundle is derivable, so only two fields are asked for:
12
+ // bucket = "<slug>_raw" (the app's convention server/auth/registry.js bucketFor)
13
+ // org = "sailkick" (single org, fleet-wide)
15
14
  //
16
- // Dependency-free; never throws.
15
+ // This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
16
+ // cloud" are separate questions, and the second one has a single fleet-wide answer that
17
+ // index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
18
+ // spooling to a loopback address forever while the status line looks healthy — the app's
19
+ // signup screen has been handing out its own internal http://localhost:8086.
20
+ //
21
+ // NB the write token cannot be re-fetched: the app shows it once at signup and never
22
+ // stores it (registry.js keeps only passwordHash + influxReadToken). Losing it means
23
+ // minting a new one, so the pasted value is the source of truth here.
17
24
 
18
25
  const fs = require('fs')
19
26
  const path = require('path')
20
27
 
21
- // Terminal = re-running with the same input can only fail the same way; the owner has
22
- // to change something (new invite, different boat name, longer password).
23
- const TERMINAL_CODES = new Set(['bad-invite', 'slug-taken', 'bad-slug', 'weak-password'])
28
+ const DEFAULT_ORG = 'sailkick'
24
29
 
25
30
  function cacheFileFor (app) {
26
31
  const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
27
32
  return path.join(dataDir, 'account.json')
28
33
  }
29
34
 
35
+ // Legacy/manual escape hatch: a hand-written (or pre-0.14.2) account.json still works,
36
+ // so an install that was set up before the token field existed keeps syncing. Any
37
+ // influxUrl in such a file is dropped — those were copied off the signup screen, which
38
+ // was showing the server's own loopback address.
30
39
  function readCache (file) {
31
40
  try {
32
41
  const j = JSON.parse(fs.readFileSync(file, 'utf8'))
33
- return (j && j.writeToken) ? j : null
42
+ if (!j || !j.writeToken) return null
43
+ const { influxUrl, ...rest } = j
44
+ return rest
34
45
  } catch { return null }
35
46
  }
36
47
 
37
- function writeCache (file, bundle) {
38
- try {
39
- fs.mkdirSync(path.dirname(file), { recursive: true })
40
- const tmp = `${file}.tmp-${process.pid}`
41
- fs.writeFileSync(tmp, JSON.stringify(bundle), { mode: 0o600 })
42
- fs.renameSync(tmp, file)
43
- } catch {}
44
- }
45
-
46
- // True when we could pair right now (nothing cached yet, but all three fields present).
47
- function pairable (account) {
48
- return !!(account && account.invite && account.slug && account.password)
49
- }
48
+ const clean = (v) => String(v == null ? '' : v).trim()
50
49
 
51
50
  // Returns one of:
52
- // { bundle, source: 'cache' } already paired no network call was made
53
- // { bundle, source: 'paired' } just provisioned; bundle now cached
54
- // { bundle: null, source: null } not configured (nothing to do)
55
- // { bundle: null, source: null, error, terminal } pairing attempted and failed
51
+ // { bundle, source: 'config' } token pasted into the plugin config
52
+ // { bundle, source: 'cache' } from a hand-written/legacy account.json
53
+ // { bundle: null, source: null, error? }
56
54
  //
57
- // bundle = { slug, influxUrl, org, bucket, writeToken }
58
- async function resolveAccountConfig (app, account, opts = {}) {
59
- const file = cacheFileFor(app)
60
-
61
- const cached = readCache(file)
62
- if (cached) return { bundle: cached, source: 'cache' } // paired already — never call signup again
63
-
64
- if (!pairable(account)) return { bundle: null, source: null }
65
-
66
- const appUrl = String(opts.appUrl || '').replace(/\/+$/, '')
67
- const url = appUrl + '/api/auth/signup'
68
- const slug = String(account.slug).trim().toLowerCase()
69
-
70
- let resp
71
- try {
72
- resp = await fetch(url, {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/json' },
75
- body: JSON.stringify({ invite: account.invite, slug, password: account.password, name: slug }),
76
- signal: AbortSignal.timeout(opts.timeoutMs || 20000)
77
- })
78
- } catch (e) {
79
- return { bundle: null, source: null, error: `cannot reach ${appUrl} (${e.message})`, terminal: false }
55
+ // bundle = { slug, org, bucket, writeToken } — no influxUrl by design, see above.
56
+ function resolveAccountConfig (app, account) {
57
+ const acct = account || {}
58
+ const slug = clean(acct.slug).toLowerCase()
59
+ const writeToken = clean(acct.writeToken)
60
+
61
+ if (slug && writeToken) {
62
+ return {
63
+ source: 'config',
64
+ bundle: {
65
+ slug,
66
+ org: clean(acct.org) || DEFAULT_ORG,
67
+ bucket: clean(acct.bucket) || `${slug}_raw`,
68
+ writeToken
69
+ }
70
+ }
80
71
  }
81
72
 
82
- const j = await resp.json().catch(() => null)
83
-
84
- if (!resp.ok || !j || j.ok === false) {
85
- const code = (j && j.code) || `http-${resp.status}`
86
- const message = (j && j.message) || `HTTP ${resp.status}`
87
- return { bundle: null, source: null, error: `${message} (${code})`, terminal: TERMINAL_CODES.has(code) }
88
- }
73
+ const cached = readCache(cacheFileFor(app))
74
+ if (cached) return { bundle: cached, source: 'cache' }
89
75
 
90
- const ingest = j.ingest || {}
91
- if (!ingest.writeToken) {
92
- return { bundle: null, source: null, error: 'signup returned no write token', terminal: false }
93
- }
94
-
95
- // NB: signup returns no readToken (the app keeps it in its own registry), so local
96
- // history falls back to the DB-less ring unless a read token is set by hand.
97
- const bundle = {
98
- slug: (j.boat && j.boat.slug) || slug,
99
- influxUrl: ingest.url,
100
- org: ingest.org,
101
- bucket: ingest.bucket,
102
- writeToken: ingest.writeToken
103
- }
104
- writeCache(file, bundle)
105
- return { bundle, source: 'paired' }
76
+ // 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' }
79
+ return { bundle: null, source: null }
106
80
  }
107
81
 
108
- // True when the plugin either holds a bundle or has enough input to obtain one.
82
+ // True when sync has credentials to work with.
109
83
  function accountConfigured (app, account) {
110
- return !!readCache(cacheFileFor(app)) || pairable(account)
84
+ return !!resolveAccountConfig(app, account).bundle
111
85
  }
112
86
 
113
- module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor }
87
+ module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor, DEFAULT_ORG }
package/lib/sync/index.js CHANGED
@@ -13,6 +13,12 @@ const { deltaToLines } = require('./lineprotocol')
13
13
 
14
14
  function createSync (app, options) {
15
15
  const log = (m) => (app.debug ? app.debug('[sync] ' + m) : console.log('[sailkick-boat:sync]', m))
16
+ // Anything that stops telemetry reaching the cloud goes to `warn`, which lands in the
17
+ // normal server log. `log` is behind the plugin's debug key and is for detail only —
18
+ // an outage must never be visible ONLY in the status line, which is what made a
19
+ // day-long silent failure possible.
20
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:sync] ' + m) : console.error('[sailkick-boat:sync]', m))
21
+ const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
16
22
  let state = null
17
23
 
18
24
  function start () {
@@ -24,8 +30,9 @@ function createSync (app, options) {
24
30
  timeoutMs: options.requestTimeoutMs || 30000
25
31
  }
26
32
  if (!cfg.influxUrl || !cfg.bucket || !cfg.token) {
27
- log('not started missing influxUrl / bucket / token')
28
- state = { statusLine: 'sync: not configured', stopped: true }
33
+ const missing = [!cfg.influxUrl && 'influxUrl', !cfg.bucket && 'bucket', !cfg.token && 'write token'].filter(Boolean)
34
+ warn(`not started missing ${missing.join(' + ')}. Telemetry is NOT being sent.`)
35
+ state = { statusLine: `sync: not configured (missing ${missing.join(', ')})`, stopped: true }
29
36
  return
30
37
  }
31
38
 
@@ -39,7 +46,26 @@ function createSync (app, options) {
39
46
  flushTimer: null, pumpTimer: null, stopped: false, unsubscribes: [],
40
47
  retryMin: options.retryMinMs || 1000, retryMax: options.retryMaxMs || 60000,
41
48
  backoff: options.retryMinMs || 1000, idlePoll: Math.max(500, options.flushIntervalMs || 1000),
42
- lastOkAt: null, pumping: false, statusLine: 'sync: starting'
49
+ lastOkAt: null, pumping: false, statusLine: 'sync: starting',
50
+ failing: false, failCount: 0, lastWarnAt: 0
51
+ }
52
+
53
+ // Outage transitions, not per-retry spam: the first failure, then at most one line
54
+ // every WARN_REPEAT_MS while it persists, then a recovery line. A boat that can
55
+ // never reach its endpoint used to produce no log output at all.
56
+ const noteFailure = (res) => {
57
+ state.failCount++
58
+ const now = Date.now()
59
+ if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
60
+ state.failing = true
61
+ state.lastWarnAt = now
62
+ const why = res && res.status ? `HTTP ${res.status}` : `unreachable${res && res.error ? ` (${res.error})` : ''}`
63
+ warn(`cannot write to ${cfg.influxUrl} — ${why}; ${state.failCount} failed attempt(s), telemetry is buffering on disk`)
64
+ }
65
+ const noteSuccess = () => {
66
+ if (state.failing) warn(`recovered — writing to ${cfg.influxUrl} again after ${state.failCount} failed attempt(s)`)
67
+ state.failing = false
68
+ state.failCount = 0
43
69
  }
44
70
 
45
71
  const handleDelta = (delta) => {
@@ -73,10 +99,15 @@ function createSync (app, options) {
73
99
  const res = await writeLines(cfg, body)
74
100
  if (res.ok) {
75
101
  await spool.remove(file); state.lastOkAt = new Date(); state.backoff = state.retryMin
102
+ noteSuccess()
76
103
  } else if (res.retryable) {
104
+ noteFailure(res)
77
105
  state.pumping = false; scheduleRetry(res); return
78
106
  } else {
79
- log(`batch rejected (HTTP ${res.status}) quarantined`); await spool.quarantine(file)
107
+ // 4xx: bad credentials or malformed data. Never retried, so if this is not
108
+ // loud the batch is simply gone.
109
+ warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} — ${res.status === 401 ? 'the write token is not valid for this bucket' : 'check bucket/org and the data'}`)
110
+ await spool.quarantine(file)
80
111
  }
81
112
  }
82
113
  } catch (e) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.14.1",
4
- "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; pairing is invite-only). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
3
+ "version": "0.14.4",
4
+ "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "node --test"