signalk-compass-calibrator 0.2.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 +14 -0
- package/README.md +78 -0
- package/index.js +1111 -0
- package/lib/angles.js +70 -0
- package/lib/calibration.js +360 -0
- package/lib/plugin-schema.js +14 -0
- package/lib/profile-store.js +150 -0
- package/lib/prometheus-history-provider.js +310 -0
- package/package.json +50 -0
- package/public/app.js +1062 -0
- package/public/icon.svg +12 -0
- package/public/index.html +179 -0
- package/public/remoteEntry.js +54 -0
- package/public/screenshot.png +0 -0
- package/public/styles.css +527 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_METRICS = {
|
|
4
|
+
headingMagnetic: 'navigation_headingMagnetic',
|
|
5
|
+
courseOverGroundTrue: 'navigation_courseOverGroundTrue',
|
|
6
|
+
speedOverGround: 'navigation_speedOverGround',
|
|
7
|
+
magneticVariation: 'navigation_magneticVariation'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PATH_TO_METRIC_KEY = {
|
|
11
|
+
'navigation.headingMagnetic': 'headingMagnetic',
|
|
12
|
+
'navigation.courseOverGroundTrue': 'courseOverGroundTrue',
|
|
13
|
+
'navigation.speedOverGround': 'speedOverGround',
|
|
14
|
+
'navigation.magneticVariation': 'magneticVariation'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class PrometheusHistoryProvider {
|
|
18
|
+
constructor (options = {}) {
|
|
19
|
+
this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '')
|
|
20
|
+
this.context = options.context || 'vessels.self'
|
|
21
|
+
this.metrics = { ...DEFAULT_METRICS, ...(options.metrics || {}) }
|
|
22
|
+
this.auth = normalizeAuth(options.auth)
|
|
23
|
+
this.fetchImpl = options.fetch || global.fetch
|
|
24
|
+
if (!this.fetchImpl) {
|
|
25
|
+
throw new Error('A fetch implementation is required')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
metricForPath (path) {
|
|
30
|
+
const key = PATH_TO_METRIC_KEY[path]
|
|
31
|
+
return key ? this.metrics[key] : path.replace(/\./g, '_')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async listSources (path, range = {}) {
|
|
35
|
+
const metric = this.metricForPath(path)
|
|
36
|
+
const sources = await this.sourcesForMetric(metric)
|
|
37
|
+
const rows = []
|
|
38
|
+
for (const source of sources) {
|
|
39
|
+
const coverage = await this.getCoverage(path, source, range).catch(error => ({
|
|
40
|
+
source,
|
|
41
|
+
path,
|
|
42
|
+
metric,
|
|
43
|
+
error: error.message,
|
|
44
|
+
sampleCount: 0,
|
|
45
|
+
coveragePercent: 0
|
|
46
|
+
}))
|
|
47
|
+
if (coverage.sampleCount > 0) rows.push(coverage)
|
|
48
|
+
}
|
|
49
|
+
return rows
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async sourcesForMetric (metric) {
|
|
53
|
+
const series = await this.instantSeries(metric)
|
|
54
|
+
const sources = unique(series
|
|
55
|
+
.filter(item => !this.context || item.metric && item.metric.context === this.context)
|
|
56
|
+
.map(item => item.metric && item.metric.source)
|
|
57
|
+
.filter(Boolean))
|
|
58
|
+
if (sources.length > 0) return sources
|
|
59
|
+
return this.labelValues('source')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async discover (paths, range = {}, resolutionSeconds = 30) {
|
|
63
|
+
const result = {}
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
result[path] = await this.listSources(path, { ...range, resolutionSeconds })
|
|
66
|
+
}
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async detectContextFromPath (path, range = {}, resolutionSeconds = 30) {
|
|
71
|
+
const metric = this.metricForPath(path)
|
|
72
|
+
const result = await this.queryRange(metric, range, resolutionSeconds)
|
|
73
|
+
const contexts = unique(result.map(series => series.metric && series.metric.context).filter(Boolean))
|
|
74
|
+
if (contexts.length === 0) return null
|
|
75
|
+
if (contexts.length === 1) return contexts[0]
|
|
76
|
+
|
|
77
|
+
const ranked = result
|
|
78
|
+
.map(series => ({
|
|
79
|
+
context: series.metric && series.metric.context,
|
|
80
|
+
samples: Array.isArray(series.values) ? series.values.length : 0
|
|
81
|
+
}))
|
|
82
|
+
.filter(item => item.context)
|
|
83
|
+
.sort((a, b) => b.samples - a.samples)
|
|
84
|
+
return ranked[0] ? ranked[0].context : contexts[0]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
withContext (context) {
|
|
88
|
+
return new PrometheusHistoryProvider({
|
|
89
|
+
baseUrl: this.baseUrl,
|
|
90
|
+
context,
|
|
91
|
+
metrics: this.metrics,
|
|
92
|
+
auth: this.auth,
|
|
93
|
+
fetch: this.fetchImpl
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async diagnosePath (path, range = {}, resolutionSeconds = 30) {
|
|
98
|
+
const metric = this.metricForPath(path)
|
|
99
|
+
const metricSeries = await this.instantSeries(metric).catch(error => ({ error: error.message, result: [] }))
|
|
100
|
+
const metricMatches = Array.isArray(metricSeries) ? metricSeries : metricSeries.result
|
|
101
|
+
const contexts = unique(metricMatches.map(series => series.metric && series.metric.context).filter(Boolean))
|
|
102
|
+
const sourcesForContext = unique(metricMatches
|
|
103
|
+
.filter(series => !this.context || series.metric && series.metric.context === this.context)
|
|
104
|
+
.map(series => series.metric && series.metric.source)
|
|
105
|
+
.filter(Boolean))
|
|
106
|
+
const rangeSeries = await this.queryRange(this.selector(metric), range, resolutionSeconds)
|
|
107
|
+
.catch(error => ({ error: error.message, result: [] }))
|
|
108
|
+
const rangeMatches = Array.isArray(rangeSeries) ? rangeSeries : rangeSeries.result
|
|
109
|
+
const sampleCount = countRangeSamples(rangeMatches)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
path,
|
|
113
|
+
metric,
|
|
114
|
+
selector: this.selector(metric),
|
|
115
|
+
metricSeriesCount: metricMatches.length,
|
|
116
|
+
contexts,
|
|
117
|
+
sourcesForSelectedContext: sourcesForContext,
|
|
118
|
+
rangeSeriesCount: rangeMatches.length,
|
|
119
|
+
rangeSampleCount: sampleCount,
|
|
120
|
+
error: metricSeries.error || rangeSeries.error || null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getSeries (path, source, range = {}, resolutionSeconds = 1) {
|
|
125
|
+
const metric = this.metricForPath(path)
|
|
126
|
+
const selector = this.selector(metric, source)
|
|
127
|
+
const result = await this.queryRange(selector, range, resolutionSeconds)
|
|
128
|
+
return rangeResultToSamples(result)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getSeriesChunked (path, source, range = {}, resolutionSeconds = 1, maxPointsPerQuery = 10000) {
|
|
132
|
+
const from = normalizeTime(range.from)
|
|
133
|
+
const to = normalizeTime(range.to)
|
|
134
|
+
if (!from || !to || to <= from) throw new Error('A valid from/to range is required')
|
|
135
|
+
|
|
136
|
+
const samples = []
|
|
137
|
+
const stepMs = Math.max(Number(resolutionSeconds || 1) * 1000, 1000)
|
|
138
|
+
const chunkMs = Math.max((Number(maxPointsPerQuery) || 10000) * stepMs, stepMs)
|
|
139
|
+
for (let chunkFrom = from; chunkFrom <= to; chunkFrom += chunkMs) {
|
|
140
|
+
const chunkTo = Math.min(chunkFrom + chunkMs - stepMs, to)
|
|
141
|
+
const chunk = await this.getSeries(path, source, {
|
|
142
|
+
from: new Date(chunkFrom).toISOString(),
|
|
143
|
+
to: new Date(chunkTo).toISOString()
|
|
144
|
+
}, resolutionSeconds)
|
|
145
|
+
samples.push(...chunk)
|
|
146
|
+
}
|
|
147
|
+
samples.sort((a, b) => a.t - b.t)
|
|
148
|
+
return dedupeSamples(samples)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async instantSeries (query) {
|
|
152
|
+
const params = new URLSearchParams({ query })
|
|
153
|
+
const data = await this.request(`/api/v1/query?${params}`)
|
|
154
|
+
return data.result || []
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async queryRange (query, range = {}, resolutionSeconds = 1) {
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
query,
|
|
160
|
+
start: toUnixSeconds(range.from),
|
|
161
|
+
end: toUnixSeconds(range.to),
|
|
162
|
+
step: String(resolutionSeconds || range.resolutionSeconds || 1)
|
|
163
|
+
})
|
|
164
|
+
const data = await this.request(`/api/v1/query_range?${params}`)
|
|
165
|
+
return data.result || []
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getCoverage (path, source, range = {}) {
|
|
169
|
+
const resolutionSeconds = Number(range.resolutionSeconds || range.step || 30)
|
|
170
|
+
const samples = await this.getSeries(path, source, range, resolutionSeconds)
|
|
171
|
+
const from = normalizeTime(range.from)
|
|
172
|
+
const to = normalizeTime(range.to)
|
|
173
|
+
const expected = from && to ? Math.max(Math.floor((to - from) / 1000 / resolutionSeconds) + 1, 1) : samples.length
|
|
174
|
+
const values = samples.map(sample => sample.value)
|
|
175
|
+
return {
|
|
176
|
+
path,
|
|
177
|
+
metric: this.metricForPath(path),
|
|
178
|
+
source,
|
|
179
|
+
sampleCount: samples.length,
|
|
180
|
+
firstSample: samples[0] ? new Date(samples[0].t).toISOString() : null,
|
|
181
|
+
lastSample: samples[samples.length - 1] ? new Date(samples[samples.length - 1].t).toISOString() : null,
|
|
182
|
+
latestValue: values.length ? values[values.length - 1] : null,
|
|
183
|
+
coveragePercent: expected ? Math.min(100, Math.round(samples.length / expected * 1000) / 10) : 0
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async labelValues (label) {
|
|
188
|
+
const data = await this.request(`/api/v1/label/${encodeURIComponent(label)}/values`)
|
|
189
|
+
return Array.isArray(data.result) ? data.result : []
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
selector (metric, source) {
|
|
193
|
+
const labels = [`context="${escapeLabel(this.context)}"`]
|
|
194
|
+
if (source) labels.push(`source="${escapeLabel(source)}"`)
|
|
195
|
+
return `${metric}{${labels.join(',')}}`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async request (path) {
|
|
199
|
+
if (!this.baseUrl) throw new Error('Prometheus baseUrl is required')
|
|
200
|
+
let response
|
|
201
|
+
try {
|
|
202
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
203
|
+
headers: this.headers()
|
|
204
|
+
})
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`Prometheus connection failed: ${error.message}`)
|
|
207
|
+
}
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const body = await readErrorBody(response)
|
|
210
|
+
const detail = response.status === 401
|
|
211
|
+
? 'authentication failed'
|
|
212
|
+
: response.status === 403
|
|
213
|
+
? 'access forbidden'
|
|
214
|
+
: response.statusText
|
|
215
|
+
throw new Error(`Prometheus request failed: ${response.status} ${detail}${body ? `: ${body}` : ''}`)
|
|
216
|
+
}
|
|
217
|
+
let body
|
|
218
|
+
try {
|
|
219
|
+
body = await response.json()
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw new Error(`Prometheus returned invalid JSON: ${error.message}`)
|
|
222
|
+
}
|
|
223
|
+
if (body.status && body.status !== 'success') {
|
|
224
|
+
throw new Error(body.error || 'Prometheus request did not succeed')
|
|
225
|
+
}
|
|
226
|
+
return body.data || body
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
headers () {
|
|
230
|
+
if (!this.auth || this.auth.type !== 'basic' || !this.auth.username) return {}
|
|
231
|
+
return {
|
|
232
|
+
authorization: `Basic ${Buffer.from(`${this.auth.username}:${this.auth.password || ''}`).toString('base64')}`
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readErrorBody (response) {
|
|
238
|
+
try {
|
|
239
|
+
const text = await response.text()
|
|
240
|
+
if (!text) return ''
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(text)
|
|
243
|
+
return parsed.error || parsed.message || text.slice(0, 500)
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return text.slice(0, 500)
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return ''
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeAuth (auth) {
|
|
253
|
+
if (!auth || typeof auth !== 'object') return null
|
|
254
|
+
if (auth.type && auth.type !== 'basic') return null
|
|
255
|
+
if (!auth.username) return null
|
|
256
|
+
return {
|
|
257
|
+
type: 'basic',
|
|
258
|
+
username: String(auth.username),
|
|
259
|
+
password: auth.password ? String(auth.password) : ''
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function unique (values) {
|
|
264
|
+
return Array.from(new Set(values)).sort()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function countRangeSamples (series) {
|
|
268
|
+
return series.reduce((sum, item) => sum + (Array.isArray(item.values) ? item.values.length : 0), 0)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function rangeResultToSamples (result) {
|
|
272
|
+
const samples = []
|
|
273
|
+
for (const series of result) {
|
|
274
|
+
for (const [timestamp, value] of series.values || []) {
|
|
275
|
+
const number = Number(value)
|
|
276
|
+
if (Number.isFinite(number)) samples.push({ t: Number(timestamp) * 1000, value: number })
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
samples.sort((a, b) => a.t - b.t)
|
|
280
|
+
return samples
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function dedupeSamples (samples) {
|
|
284
|
+
const byTime = new Map()
|
|
285
|
+
for (const sample of samples) byTime.set(sample.t, sample)
|
|
286
|
+
return Array.from(byTime.values()).sort((a, b) => a.t - b.t)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function escapeLabel (value) {
|
|
290
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function normalizeTime (value) {
|
|
294
|
+
if (!value) return null
|
|
295
|
+
if (value instanceof Date) return value.getTime()
|
|
296
|
+
if (typeof value === 'number') return value < 100000000000 ? value * 1000 : value
|
|
297
|
+
const time = new Date(value).getTime()
|
|
298
|
+
return Number.isFinite(time) ? time : null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function toUnixSeconds (value) {
|
|
302
|
+
const time = normalizeTime(value)
|
|
303
|
+
if (!time) throw new Error('A valid from/to range is required')
|
|
304
|
+
return String(Math.floor(time / 1000))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
DEFAULT_METRICS,
|
|
309
|
+
PrometheusHistoryProvider
|
|
310
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-compass-calibrator",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Signal K plugin that calibrates magnetic heading from source-aware historical navigation data.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"lib",
|
|
12
|
+
"public",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"signalk-node-server-plugin",
|
|
18
|
+
"signalk-embeddable-webapp",
|
|
19
|
+
"signalk-webapp",
|
|
20
|
+
"signalk-category-utility",
|
|
21
|
+
"signalk",
|
|
22
|
+
"compass",
|
|
23
|
+
"calibration",
|
|
24
|
+
"victoriametrics",
|
|
25
|
+
"prometheus"
|
|
26
|
+
],
|
|
27
|
+
"author": "Jean-Laurent Girod (@macjl)",
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"homepage": "https://github.com/macjl/signalk-compass-calibrator#readme",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/macjl/signalk-compass-calibrator.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/macjl/signalk-compass-calibrator/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"devDependencies": {},
|
|
42
|
+
"signalk-plugin-enabled-by-default": false,
|
|
43
|
+
"signalk": {
|
|
44
|
+
"displayName": "Compass Calibrator",
|
|
45
|
+
"appIcon": "./public/icon.svg",
|
|
46
|
+
"screenshots": [
|
|
47
|
+
"./public/screenshot.png"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|