sailkick-boat 0.14.1 → 0.14.3
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 +26 -12
- package/index.js +38 -22
- package/lib/account/index.js +55 -81
- package/package.json +2 -2
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
|
-
> **
|
|
11
|
-
> this
|
|
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,36 @@ 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:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
> **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
|
|
175
|
+
> the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
|
|
176
|
+
> `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
|
|
177
|
+
> so a wrong value can never redirect your telemetry. (Self-hosters: hand-edit
|
|
178
|
+
> `sync.influxUrl` in the plugin config JSON.) If the status line ever warns that sync is
|
|
179
|
+
> writing to a local address, telemetry is not leaving the boat.
|
|
180
|
+
|
|
181
|
+
Registration deliberately happens **only** in the web app. The plugin cannot sign up,
|
|
182
|
+
so there is no way to half-create an account or burn an invite from the boat.
|
|
183
|
+
|
|
184
|
+
> Changing the token later — after a rotation, say — is just editing the field and
|
|
185
|
+
> saving. Pasted values are trimmed, and the boat name is lower-cased for you.
|
|
172
186
|
|
|
173
187
|
## Config
|
|
174
188
|
The page is deliberately small — everything else has a right answer and is a constant
|
|
175
189
|
in `index.js`.
|
|
176
190
|
|
|
177
|
-
- **Sailkick account**: `
|
|
191
|
+
- **Sailkick account**: `slug` (boat name), `writeToken`
|
|
178
192
|
- **Telemetry sync → cloud**: `enabled`
|
|
179
193
|
- **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
|
|
180
194
|
(default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
|
package/index.js
CHANGED
|
@@ -45,6 +45,17 @@ const HISTORY_TUNING = {
|
|
|
45
45
|
ringSampleSec: 15
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// A *cloud* endpoint on loopback means telemetry never leaves the boat. On the wire it
|
|
49
|
+
// is indistinguishable from a normal offline backlog — the spool just grows — so it has
|
|
50
|
+
// to be called out explicitly. (The local history DB is legitimately on 127.0.0.1; this
|
|
51
|
+
// check is only ever applied to the sync path.)
|
|
52
|
+
function isLoopbackUrl (u) {
|
|
53
|
+
try {
|
|
54
|
+
const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
|
|
55
|
+
return h === 'localhost' || h === '::1' || /^127\./.test(h)
|
|
56
|
+
} catch { return false }
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
module.exports = function (app) {
|
|
49
60
|
let sync = null
|
|
50
61
|
let proxy = null
|
|
@@ -52,6 +63,7 @@ module.exports = function (app) {
|
|
|
52
63
|
let history = null
|
|
53
64
|
let statusTimer = null
|
|
54
65
|
let accountStatus = null
|
|
66
|
+
let syncWarning = null
|
|
55
67
|
|
|
56
68
|
const plugin = {
|
|
57
69
|
id: 'sailkick-boat',
|
|
@@ -66,12 +78,11 @@ module.exports = function (app) {
|
|
|
66
78
|
properties: {
|
|
67
79
|
account: {
|
|
68
80
|
type: 'object',
|
|
69
|
-
title: 'Sailkick account
|
|
70
|
-
description: '
|
|
81
|
+
title: 'Sailkick account',
|
|
82
|
+
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
83
|
properties: {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
password: { type: 'string', title: 'Password', description: 'At least 8 characters.' }
|
|
84
|
+
slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
|
|
85
|
+
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
86
|
}
|
|
76
87
|
},
|
|
77
88
|
sync: {
|
|
@@ -115,23 +126,17 @@ module.exports = function (app) {
|
|
|
115
126
|
|
|
116
127
|
plugin.start = function (options) {
|
|
117
128
|
const opts = options || {}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (r.
|
|
124
|
-
|
|
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
|
-
}
|
|
129
|
+
try {
|
|
130
|
+
// Purely local: the boat registers on the website, the owner pastes the write
|
|
131
|
+
// token here. Nothing to fetch, so nothing that can fail while offline.
|
|
132
|
+
const r = resolveAccountConfig(app, opts.account)
|
|
133
|
+
if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
|
|
134
|
+
else if (r.error) accountStatus = `account: ${r.error}`
|
|
135
|
+
else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
|
|
133
136
|
startModules(opts, r.bundle)
|
|
134
|
-
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
(app.error || console.error)('[sailkick-boat] start failed: ' + e.message)
|
|
139
|
+
}
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
function startModules (opts, bundle) {
|
|
@@ -151,7 +156,12 @@ module.exports = function (app) {
|
|
|
151
156
|
if (s.enabled !== false) {
|
|
152
157
|
const syncOpts = {
|
|
153
158
|
...SYNC_TUNING,
|
|
154
|
-
|
|
159
|
+
// The cloud endpoint is fleet-wide and fixed. A pasted or cached bundle can
|
|
160
|
+
// never set it — the app's signup screen hands out its own http://localhost:8086,
|
|
161
|
+
// and a boat that believed it would spool to loopback forever, looking healthy.
|
|
162
|
+
// `sync.influxUrl` survives only as a self-hosting escape hatch: it is not in
|
|
163
|
+
// the schema, so it cannot arrive by pasting, only by editing the config JSON.
|
|
164
|
+
influxUrl: s.influxUrl || SAILKICK_INFLUX_URL,
|
|
155
165
|
org: b.org || s.org || SYNC_TUNING.org,
|
|
156
166
|
bucket: b.bucket || s.bucket,
|
|
157
167
|
token: b.writeToken || s.token,
|
|
@@ -164,6 +174,10 @@ module.exports = function (app) {
|
|
|
164
174
|
retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
|
|
165
175
|
retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
|
|
166
176
|
}
|
|
177
|
+
if (isLoopbackUrl(syncOpts.influxUrl)) {
|
|
178
|
+
syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — telemetry is NOT reaching the cloud`
|
|
179
|
+
;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a local address — telemetry will not reach the cloud`)
|
|
180
|
+
}
|
|
167
181
|
try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
|
|
168
182
|
(app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
|
|
169
183
|
sync = null
|
|
@@ -255,6 +269,7 @@ module.exports = function (app) {
|
|
|
255
269
|
if (!app.setPluginStatus) return
|
|
256
270
|
const parts = []
|
|
257
271
|
if (accountStatus) parts.push(accountStatus)
|
|
272
|
+
if (syncWarning) parts.push(syncWarning)
|
|
258
273
|
if (sync) parts.push(sync.status())
|
|
259
274
|
if (proxy) parts.push(proxy.status())
|
|
260
275
|
if (telemetry) parts.push(telemetry.status())
|
|
@@ -274,6 +289,7 @@ module.exports = function (app) {
|
|
|
274
289
|
history = null
|
|
275
290
|
proxy = null
|
|
276
291
|
accountStatus = null
|
|
292
|
+
syncWarning = null
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
// Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
|
package/lib/account/index.js
CHANGED
|
@@ -1,113 +1,87 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// Sailkick account
|
|
3
|
+
// Sailkick account config — a pure local resolver, no network.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
53
|
-
// { bundle, source: '
|
|
54
|
-
// { bundle: null, source: null }
|
|
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,
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
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
|
|
82
|
+
// True when sync has credentials to work with.
|
|
109
83
|
function accountConfigured (app, account) {
|
|
110
|
-
return !!
|
|
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.14.
|
|
4
|
-
"description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io;
|
|
3
|
+
"version": "0.14.3",
|
|
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"
|