signalk-questdb 1.8.3 → 1.9.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/.prettierignore +1 -0
- package/README.md +2 -2
- package/dist/api-contract.d.ts +29 -9
- package/dist/configpanel/responses.d.ts +1 -11
- package/dist/configpanel/responses.js +0 -19
- package/dist/configpanel/responses.js.map +1 -1
- package/dist/delta-routing.d.ts +55 -1
- package/dist/delta-routing.js +110 -1
- package/dist/delta-routing.js.map +1 -1
- package/dist/index.js +146 -18
- package/dist/index.js.map +1 -1
- package/dist/questdb-endpoint.d.ts +14 -0
- package/dist/questdb-endpoint.js.map +1 -1
- package/dist/restore.js +107 -16
- package/dist/restore.js.map +1 -1
- package/dist/test/configpanel-responses.test.js +1 -64
- package/dist/test/configpanel-responses.test.js.map +1 -1
- package/dist/test/delta-routing.test.js +203 -19
- package/dist/test/delta-routing.test.js.map +1 -1
- package/dist/test/restore.test.js +107 -0
- package/dist/test/restore.test.js.map +1 -1
- package/package.json +8 -3
- package/public/assets/PluginConfigurationPanel-Wr6ovJk-.js +9 -0
- package/public/assets/{index-C6iuilck.js → index-DYF3UJ1p.js} +1 -1
- package/public/assets/{virtualExposes-Cj0hoMce.js → virtualExposes-Dz9bQoz2.js} +1 -1
- package/public/remoteEntry.js +1 -1
- package/public/assets/PluginConfigurationPanel-u1H_dS78.js +0 -10
package/.prettierignore
CHANGED
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ The plugin embeds a React config panel in the Signal K Admin UI showing:
|
|
|
34
34
|
- **Image Version** -- dropdown with latest, pre-releases, and last 3 stable releases
|
|
35
35
|
- **Connection** -- managed container toggle, host/ports, PostgreSQL port for Grafana
|
|
36
36
|
- **Recording** -- record self, record AIS targets, startup restore, console webapp, retention days
|
|
37
|
-
- **Path filtering** (collapsible) -- exclude or include-only paths with glob patterns
|
|
37
|
+
- **Path filtering** (collapsible) -- exclude or include-only paths with glob patterns
|
|
38
38
|
- **Compression** (collapsible) -- LZ4/ZSTD codec selection for on-disk storage
|
|
39
39
|
- **InfluxDB Migration** (collapsible) -- auto-detect with manual URL for remote instances
|
|
40
40
|
- **Data Export** (collapsible) -- date range picker, Parquet/CSV format, download button
|
|
@@ -133,7 +133,7 @@ wants to slice it into kopia-dedup-friendly shards. Allowed tables:
|
|
|
133
133
|
| QuestDB console webapp | `true` | Serve QuestDB's console in the Signal K admin UI, admin only (see Console webapp) |
|
|
134
134
|
| Retention (days) | `0` | Auto-delete old partitions (0 = keep forever) |
|
|
135
135
|
| Path filter mode | `exclude` | `exclude` matching paths, or `include` only matching paths |
|
|
136
|
-
| Path filter paths | _(empty)_ | Glob patterns, one per line (e.g. `
|
|
136
|
+
| Path filter paths | _(empty)_ | Glob patterns, one per line (e.g. `notifications.*`); empty = record everything, which is the default |
|
|
137
137
|
| Compression codec | `lz4` | On-disk WAL compression: `none`, `lz4`, or `zstd` |
|
|
138
138
|
| Compression level | `3` | ZSTD level 1-22 (only when codec is zstd) |
|
|
139
139
|
| Container network | `sk-network` | Shared network for QuestDB (only applied when binding to 0.0.0.0) |
|
package/dist/api-contract.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { UlimitClamp } from "signalk-container-helper";
|
|
1
2
|
import type { MaxMapCountStatus } from "./host-limits.js";
|
|
2
3
|
import type { SkipPlan, SuspendedTable } from "./wal-monitor.js";
|
|
3
4
|
/**
|
|
@@ -11,16 +12,15 @@ export interface ApiError {
|
|
|
11
12
|
message?: string;
|
|
12
13
|
}
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* signalk-container's `UlimitClamp` event, re-exported under the name the
|
|
16
|
+
* API responses use.
|
|
17
|
+
*
|
|
18
|
+
* `import type` is load-bearing: this file is shared with the browser panel,
|
|
19
|
+
* and the helper's main entry carries ~19KB of Node-side runtime code. A
|
|
20
|
+
* value import would drag all of it into the federated bundle. The type-only
|
|
21
|
+
* form is erased at compile time, so nothing ships.
|
|
17
22
|
*/
|
|
18
|
-
export
|
|
19
|
-
ulimit: string;
|
|
20
|
-
requested: number;
|
|
21
|
-
granted: number;
|
|
22
|
-
reason: string;
|
|
23
|
-
}
|
|
23
|
+
export type UlimitClampStatus = UlimitClamp;
|
|
24
24
|
export type SuspendedTableStatus = SuspendedTable & {
|
|
25
25
|
/** WAL monitor's verdict for the current stall point: "pending" | "failed". */
|
|
26
26
|
autoResume: string | null;
|
|
@@ -51,6 +51,26 @@ export interface DbStatus extends ApiError {
|
|
|
51
51
|
* display name gets an empty statusMessage and the count would be invisible.
|
|
52
52
|
*/
|
|
53
53
|
restore?: RestoreStatus | null;
|
|
54
|
+
/**
|
|
55
|
+
* Paths seen carrying a value no table can hold — arrays, or objects nested
|
|
56
|
+
* deeper than the one level that gets flattened. Absent when nothing has
|
|
57
|
+
* been dropped. Surfaced because the previous behaviour was to drop them
|
|
58
|
+
* silently, which is how attitude went unrecorded unnoticed (issue #128).
|
|
59
|
+
*/
|
|
60
|
+
unstorable?: UnstorableStatus | null;
|
|
61
|
+
}
|
|
62
|
+
/** Values encountered that cannot be represented in any table. */
|
|
63
|
+
export interface UnstorableStatus {
|
|
64
|
+
/**
|
|
65
|
+
* Distinct paths dropped since the plugin started. A LOWER BOUND when
|
|
66
|
+
* `truncated` is set: tracking is capped, because Signal K paths embed
|
|
67
|
+
* instance and vessel identifiers and so are not a bounded vocabulary.
|
|
68
|
+
*/
|
|
69
|
+
paths: number;
|
|
70
|
+
/** True once the cap was hit and further distinct paths stopped being kept. */
|
|
71
|
+
truncated: boolean;
|
|
72
|
+
/** A sample of those paths, for identifying them without reading logs. */
|
|
73
|
+
examples: string[];
|
|
54
74
|
}
|
|
55
75
|
/** Startup-restore outcome, shown on the config panel. */
|
|
56
76
|
export interface RestoreStatus {
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
import type { MigrationSource
|
|
2
|
-
/**
|
|
3
|
-
* GET /api/versions returns an array of releases. Anything else becomes an
|
|
4
|
-
* empty list, and malformed members are dropped: the version dropdown is
|
|
5
|
-
* then short or empty rather than the panel being gone.
|
|
6
|
-
*
|
|
7
|
-
* Members are validated, not just the array — `versions.filter(v =>
|
|
8
|
-
* !v.prerelease)` runs during render, so a single null element is as fatal
|
|
9
|
-
* as a non-array body.
|
|
10
|
-
*/
|
|
11
|
-
export declare function toVersionList(body: unknown): QuestdbVersion[];
|
|
1
|
+
import type { MigrationSource } from "../api-contract.js";
|
|
12
2
|
/**
|
|
13
3
|
* GET /api/migration/detect returns `{ sources: [...] }`. A body without it
|
|
14
4
|
* means "nothing detected", which is also what an empty array means — so the
|
|
@@ -9,25 +9,6 @@
|
|
|
9
9
|
//
|
|
10
10
|
// These helpers keep the "trust but verify" boundary in one testable place.
|
|
11
11
|
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
12
|
-
/**
|
|
13
|
-
* GET /api/versions returns an array of releases. Anything else becomes an
|
|
14
|
-
* empty list, and malformed members are dropped: the version dropdown is
|
|
15
|
-
* then short or empty rather than the panel being gone.
|
|
16
|
-
*
|
|
17
|
-
* Members are validated, not just the array — `versions.filter(v =>
|
|
18
|
-
* !v.prerelease)` runs during render, so a single null element is as fatal
|
|
19
|
-
* as a non-array body.
|
|
20
|
-
*/
|
|
21
|
-
export function toVersionList(body) {
|
|
22
|
-
if (!Array.isArray(body))
|
|
23
|
-
return [];
|
|
24
|
-
return body.filter((v) => isRecord(v) &&
|
|
25
|
-
typeof v.tag === "string" &&
|
|
26
|
-
// Not merely cosmetic: the two filters below partition on this, so a
|
|
27
|
-
// missing or non-boolean value would list a pre-release as stable —
|
|
28
|
-
// and the stable list is what the version dropdown defaults to.
|
|
29
|
-
typeof v.prerelease === "boolean");
|
|
30
|
-
}
|
|
31
12
|
/**
|
|
32
13
|
* GET /api/migration/detect returns `{ sources: [...] }`. A body without it
|
|
33
14
|
* means "nothing detected", which is also what an empty array means — so the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../../src/configpanel/responses.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,wEAAwE;AACxE,4EAA4E;AAC5E,uEAAuE;AACvE,EAAE;AACF,4EAA4E;AAI5E,MAAM,QAAQ,GAAG,CAAC,CAAU,EAAgC,EAAE,CAC5D,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,CAAC;AAEtC
|
|
1
|
+
{"version":3,"file":"responses.js","sourceRoot":"","sources":["../../src/configpanel/responses.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,wEAAwE;AACxE,4EAA4E;AAC5E,uEAAuE;AACvE,EAAE;AACF,4EAA4E;AAI5E,MAAM,QAAQ,GAAG,CAAC,CAAU,EAAgC,EAAE,CAC5D,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,CAAC;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAa;IAC9C,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IACvC,OAAO,OAAO,CAAC,MAAM,CACnB,CAAC,CAAC,EAAwB,EAAE,CAC1B,QAAQ,CAAC,CAAC,CAAC;QACX,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAC1B,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ;QACzB,iEAAiE;QACjE,kEAAkE;QAClE,iEAAiE;QACjE,sDAAsD;QACtD,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,CAAC,CAC7D,CAAC;AACJ,CAAC"}
|
package/dist/delta-routing.d.ts
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
export type DeltaRoute = "number" | "string" | "boolean" | "position" | null;
|
|
1
|
+
export type DeltaRoute = "number" | "string" | "boolean" | "position" | "flatten" | null;
|
|
2
|
+
/** One scalar leaf pulled out of an object value, ready to record. */
|
|
3
|
+
export interface FlattenedLeaf {
|
|
4
|
+
/** Parent path plus the key, e.g. `navigation.attitude.roll`. */
|
|
5
|
+
path: string;
|
|
6
|
+
value: number | string | boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Remembers which paths carried a value no table can hold, so the drop can be
|
|
10
|
+
* reported instead of happening in silence — the underlying bug behind #128.
|
|
11
|
+
*
|
|
12
|
+
* Capped, because the path vocabulary is NOT bounded: paths embed instance
|
|
13
|
+
* identifiers (`watermaker.0.*`) and notifications embed per-vessel ones, so
|
|
14
|
+
* an unstorable shape on a busy AIS stream would grow an uncapped set for the
|
|
15
|
+
* lifetime of the process. Past the cap the count still rises but no new path
|
|
16
|
+
* is retained, and `truncated` marks the count as a lower bound rather than
|
|
17
|
+
* letting it read as exact.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately does no logging: `note()` runs per delta on a stream carrying
|
|
20
|
+
* 100+ values/sec, and building a message string there costs whether or not
|
|
21
|
+
* debug is enabled.
|
|
22
|
+
*/
|
|
23
|
+
export declare class UnstorableTracker {
|
|
24
|
+
private readonly cap;
|
|
25
|
+
private readonly paths;
|
|
26
|
+
private capped;
|
|
27
|
+
constructor(cap?: number);
|
|
28
|
+
/** Records a path. Cheap and idempotent; safe to call per delta. */
|
|
29
|
+
note(path: string): void;
|
|
30
|
+
get size(): number;
|
|
31
|
+
get truncated(): boolean;
|
|
32
|
+
examples(limit: number): string[];
|
|
33
|
+
clear(): void;
|
|
34
|
+
}
|
|
2
35
|
export declare function extractVesselName(path: string, value: unknown): string | null;
|
|
3
36
|
export declare function routeDeltaValue(path: string, value: unknown): DeltaRoute;
|
|
37
|
+
/**
|
|
38
|
+
* Scalar leaves of an object value, as dotted paths.
|
|
39
|
+
*
|
|
40
|
+
* navigation.attitude {roll: 0.02, yaw: 1.57}
|
|
41
|
+
* -> navigation.attitude.roll 0.02
|
|
42
|
+
* -> navigation.attitude.yaw 1.57
|
|
43
|
+
*
|
|
44
|
+
* **One level deep, deliberately.** That covers attitude and effectively every
|
|
45
|
+
* real Signal K object, while a recursive walk would happily write out whole
|
|
46
|
+
* nested payloads (notifications, resource documents) that nobody asked to
|
|
47
|
+
* record. A nested object is therefore skipped, not descended into — and
|
|
48
|
+
* reported, so it is not another silent drop.
|
|
49
|
+
*
|
|
50
|
+
* The leaves are ordinary scalar paths, which is what makes this cheap: no
|
|
51
|
+
* schema change, and dedup, retention, sampling, path filtering and both
|
|
52
|
+
* history APIs apply to them unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export declare function flattenObjectValue(path: string, value: unknown): {
|
|
55
|
+
leaves: FlattenedLeaf[];
|
|
56
|
+
skipped: string[];
|
|
57
|
+
};
|
package/dist/delta-routing.js
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remembers which paths carried a value no table can hold, so the drop can be
|
|
3
|
+
* reported instead of happening in silence — the underlying bug behind #128.
|
|
4
|
+
*
|
|
5
|
+
* Capped, because the path vocabulary is NOT bounded: paths embed instance
|
|
6
|
+
* identifiers (`watermaker.0.*`) and notifications embed per-vessel ones, so
|
|
7
|
+
* an unstorable shape on a busy AIS stream would grow an uncapped set for the
|
|
8
|
+
* lifetime of the process. Past the cap the count still rises but no new path
|
|
9
|
+
* is retained, and `truncated` marks the count as a lower bound rather than
|
|
10
|
+
* letting it read as exact.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately does no logging: `note()` runs per delta on a stream carrying
|
|
13
|
+
* 100+ values/sec, and building a message string there costs whether or not
|
|
14
|
+
* debug is enabled.
|
|
15
|
+
*/
|
|
16
|
+
export class UnstorableTracker {
|
|
17
|
+
cap;
|
|
18
|
+
paths = new Set();
|
|
19
|
+
capped = false;
|
|
20
|
+
constructor(cap = 50) {
|
|
21
|
+
this.cap = cap;
|
|
22
|
+
}
|
|
23
|
+
/** Records a path. Cheap and idempotent; safe to call per delta. */
|
|
24
|
+
note(path) {
|
|
25
|
+
if (this.paths.has(path))
|
|
26
|
+
return;
|
|
27
|
+
if (this.paths.size >= this.cap) {
|
|
28
|
+
this.capped = true;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.paths.add(path);
|
|
32
|
+
}
|
|
33
|
+
get size() {
|
|
34
|
+
return this.paths.size;
|
|
35
|
+
}
|
|
36
|
+
get truncated() {
|
|
37
|
+
return this.capped;
|
|
38
|
+
}
|
|
39
|
+
examples(limit) {
|
|
40
|
+
return [...this.paths].slice(0, limit);
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
this.paths.clear();
|
|
44
|
+
this.capped = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
1
47
|
// Static vessel identity arrives as EMPTY-path object deltas —
|
|
2
48
|
// `{path: "", value: {name: "..."}}` is how AIS static reports reach the
|
|
3
49
|
// server, and the exact shape Freeboard reads names from. These never make
|
|
@@ -13,8 +59,15 @@ export function extractVesselName(path, value) {
|
|
|
13
59
|
return typeof name === "string" && name.trim() !== "" ? name : null;
|
|
14
60
|
}
|
|
15
61
|
export function routeDeltaValue(path, value) {
|
|
62
|
+
// NaN and ±Infinity have no ILP representation. QuestDB does NOT reject
|
|
63
|
+
// them — verified against a live instance, `value=NaN` is accepted and
|
|
64
|
+
// stored — so an unguarded sensor fault silently poisons the numeric
|
|
65
|
+
// column, and every aggregate over that path afterwards. A source
|
|
66
|
+
// reporting a non-finite number is reporting "no reading", which is what
|
|
67
|
+
// recording nothing means. flattenObjectValue already applied this to
|
|
68
|
+
// leaves; the top-level path has to agree.
|
|
16
69
|
if (typeof value === "number")
|
|
17
|
-
return "number";
|
|
70
|
+
return Number.isFinite(value) ? "number" : null;
|
|
18
71
|
if (typeof value === "string")
|
|
19
72
|
return "string";
|
|
20
73
|
// Booleans are everywhere in Signal K — switch and relay states, pump and
|
|
@@ -34,6 +87,62 @@ export function routeDeltaValue(path, value) {
|
|
|
34
87
|
Number.isFinite(value.latitude) &&
|
|
35
88
|
Number.isFinite(value.longitude))
|
|
36
89
|
return "position";
|
|
90
|
+
// Any other non-null, non-array object: record its scalar leaves
|
|
91
|
+
// individually. Arrays are excluded deliberately — their indices are not
|
|
92
|
+
// stable identities, so `foo.0` would silently mean a different thing from
|
|
93
|
+
// one delta to the next.
|
|
94
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value))
|
|
95
|
+
return "flatten";
|
|
37
96
|
return null;
|
|
38
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Scalar leaves of an object value, as dotted paths.
|
|
100
|
+
*
|
|
101
|
+
* navigation.attitude {roll: 0.02, yaw: 1.57}
|
|
102
|
+
* -> navigation.attitude.roll 0.02
|
|
103
|
+
* -> navigation.attitude.yaw 1.57
|
|
104
|
+
*
|
|
105
|
+
* **One level deep, deliberately.** That covers attitude and effectively every
|
|
106
|
+
* real Signal K object, while a recursive walk would happily write out whole
|
|
107
|
+
* nested payloads (notifications, resource documents) that nobody asked to
|
|
108
|
+
* record. A nested object is therefore skipped, not descended into — and
|
|
109
|
+
* reported, so it is not another silent drop.
|
|
110
|
+
*
|
|
111
|
+
* The leaves are ordinary scalar paths, which is what makes this cheap: no
|
|
112
|
+
* schema change, and dedup, retention, sampling, path filtering and both
|
|
113
|
+
* history APIs apply to them unchanged.
|
|
114
|
+
*/
|
|
115
|
+
export function flattenObjectValue(path, value) {
|
|
116
|
+
const leaves = [];
|
|
117
|
+
const skipped = [];
|
|
118
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
119
|
+
return { leaves, skipped };
|
|
120
|
+
}
|
|
121
|
+
// An empty parent would build ".name" — a leading-dot path that matches no
|
|
122
|
+
// Signal K path and no filter pattern. The recorder already returns before
|
|
123
|
+
// reaching here for path "" (that shape is the AIS static report, handled
|
|
124
|
+
// by extractVesselName), so this is belt-and-braces: the function must not
|
|
125
|
+
// depend on a caller's guard to avoid emitting a malformed path.
|
|
126
|
+
if (path === "")
|
|
127
|
+
return { leaves, skipped };
|
|
128
|
+
for (const [key, leaf] of Object.entries(value)) {
|
|
129
|
+
const leafPath = `${path}.${key}`;
|
|
130
|
+
if (typeof leaf === "number") {
|
|
131
|
+
// NaN and ±Infinity have no ILP representation and would poison the
|
|
132
|
+
// column; a sensor reporting them is reporting "no reading".
|
|
133
|
+
if (Number.isFinite(leaf))
|
|
134
|
+
leaves.push({ path: leafPath, value: leaf });
|
|
135
|
+
else
|
|
136
|
+
skipped.push(leafPath);
|
|
137
|
+
}
|
|
138
|
+
else if (typeof leaf === "string" || typeof leaf === "boolean") {
|
|
139
|
+
leaves.push({ path: leafPath, value: leaf });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// Nested object, array, null or undefined.
|
|
143
|
+
skipped.push(leafPath);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { leaves, skipped };
|
|
147
|
+
}
|
|
39
148
|
//# sourceMappingURL=delta-routing.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { waitForContainerManager } from "signalk-container-helper";
|
|
1
2
|
import { ILPWriter } from "./ilp-writer.js";
|
|
2
3
|
import { QueryClient, isReadOnlySQL } from "./query-client.js";
|
|
3
4
|
import { ConfigSchema, normalizeConfig } from "./config/schema.js";
|
|
4
|
-
import { extractVesselName, routeDeltaValue } from "./delta-routing.js";
|
|
5
|
+
import { extractVesselName, flattenObjectValue, routeDeltaValue, UnstorableTracker, } from "./delta-routing.js";
|
|
5
6
|
import { createHistoryProviderV2 } from "./history-v2.js";
|
|
6
7
|
import { createHistoryProviderV1 } from "./history-v1.js";
|
|
7
8
|
import { startRetention } from "./retention.js";
|
|
@@ -13,6 +14,12 @@ import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL
|
|
|
13
14
|
import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
|
|
14
15
|
import { buildContainerEnv } from "./container-env.js";
|
|
15
16
|
import { PathMatcher, RateMatcher, Throttle } from "./path-matcher.js";
|
|
17
|
+
// signalk-container's API, imported rather than mirrored. The 156-line
|
|
18
|
+
// hand-written copy that used to live here drifted by construction: it was
|
|
19
|
+
// only ever updated when this plugin happened to need a new member, so
|
|
20
|
+
// getContainerNofile sat unmirrored from 1.25.3 until a probe needed it.
|
|
21
|
+
// signalk-container-helper carries the same types and pins them against
|
|
22
|
+
// signalk-container/types in CI, so a drift fails there instead of here.
|
|
16
23
|
// The managed QuestDB container's name. signalk-container prefixes it with
|
|
17
24
|
// `sk-` for the actual container and its DNS name (e.g. `sk-signalk-questdb`).
|
|
18
25
|
const QUESTDB_CONTAINER_NAME = "signalk-questdb";
|
|
@@ -184,6 +191,13 @@ export default (app) => {
|
|
|
184
191
|
// without creating resources, so an aborted start can't resurrect a
|
|
185
192
|
// just-purged container.
|
|
186
193
|
let startAbort = null;
|
|
194
|
+
// How long a start waits for signalk-container to publish its API and for
|
|
195
|
+
// runtime detection to settle. Generous because both are one-off startup
|
|
196
|
+
// costs on a cold boot — signalk-container may still be pulling its own
|
|
197
|
+
// image — and the alternative to waiting is a spurious "plugin required"
|
|
198
|
+
// error on a system that has it. A purge aborts the wait, so a wedged start
|
|
199
|
+
// does not hold the lock for this long.
|
|
200
|
+
const CONTAINER_MANAGER_TIMEOUT_MS = 120000;
|
|
187
201
|
// How long purge waits to acquire the lifecycle lock before proceeding
|
|
188
202
|
// anyway. After this it forces its teardown through even if a hung start
|
|
189
203
|
// never released the lock (see PURGE_LOCK_TIMEOUT_MS use below).
|
|
@@ -346,8 +360,22 @@ export default (app) => {
|
|
|
346
360
|
[`${QUESTDB_INTERNAL_ILP_PORT}/tcp`]: `${bind}:${ilpPort}`,
|
|
347
361
|
[`${QUESTDB_INTERNAL_PG_PORT}/tcp`]: `${bind}:${pgPort}`,
|
|
348
362
|
};
|
|
363
|
+
// ensureNetwork is feature-detected: the helper types every
|
|
364
|
+
// version-gated member as optional so a plugin degrades on an older
|
|
365
|
+
// signalk-container rather than throwing. The previous hand-written
|
|
366
|
+
// mirror declared it required, which hid the need for this guard —
|
|
367
|
+
// and the connectToNetwork call further down was already guarding, so
|
|
368
|
+
// the two disagreed about the same API.
|
|
369
|
+
//
|
|
370
|
+
// networkMode is set REGARDLESS. Creating the network is the optional
|
|
371
|
+
// step; joining it is what the companion Grafana depends on, and on a
|
|
372
|
+
// signalk-container without ensureNetwork the network the operator
|
|
373
|
+
// named almost certainly exists already. Gating the assignment on the
|
|
374
|
+
// probe would silently drop the container off that network.
|
|
349
375
|
if (config.networkName) {
|
|
350
|
-
|
|
376
|
+
if (typeof containers.ensureNetwork === "function") {
|
|
377
|
+
await containers.ensureNetwork(config.networkName);
|
|
378
|
+
}
|
|
351
379
|
containerConfig.networkMode = config.networkName;
|
|
352
380
|
}
|
|
353
381
|
// A 0.0.0.0-published port is reached on 127.0.0.1 or via the host
|
|
@@ -374,10 +402,17 @@ export default (app) => {
|
|
|
374
402
|
// endpoint from whatever address signalk-container reports.
|
|
375
403
|
containerConfig.signalkAccessiblePorts = QUESTDB_ACCESSIBLE_PORTS;
|
|
376
404
|
return async () => {
|
|
405
|
+
// The two are feature-detected SEPARATELY. Requiring both would skip
|
|
406
|
+
// attachment entirely on a signalk-container that has connectToNetwork
|
|
407
|
+
// but not ensureNetwork -- and there the network almost certainly
|
|
408
|
+
// already exists, so attaching still works. Creating it is the
|
|
409
|
+
// optional step; joining it is the one Grafana depends on.
|
|
377
410
|
if (config.networkName &&
|
|
378
411
|
typeof containers.connectToNetwork === "function") {
|
|
379
412
|
try {
|
|
380
|
-
|
|
413
|
+
if (typeof containers.ensureNetwork === "function") {
|
|
414
|
+
await containers.ensureNetwork(config.networkName);
|
|
415
|
+
}
|
|
381
416
|
await containers.connectToNetwork(name, config.networkName);
|
|
382
417
|
}
|
|
383
418
|
catch (err) {
|
|
@@ -394,6 +429,18 @@ export default (app) => {
|
|
|
394
429
|
// 100% CPU with an 89-pattern filter list.
|
|
395
430
|
let pathFilterMatcher = null;
|
|
396
431
|
let rateMatcher = null;
|
|
432
|
+
// Paths carrying a value with no representation in any table: arrays, and
|
|
433
|
+
// objects nested below the one level `flattenObjectValue` descends. These
|
|
434
|
+
// used to vanish without any trace at all — the whole reason
|
|
435
|
+
// navigation.attitude went unnoticed until a user asked (issue #128) — so
|
|
436
|
+
// record them and surface the count on /api/status.
|
|
437
|
+
//
|
|
438
|
+
// See UnstorableTracker: capped, and deliberately silent on the per-delta
|
|
439
|
+
// path. The diagnostic surfaces on /api/status, which is where a user will
|
|
440
|
+
// actually see it — this whole class of loss went unnoticed precisely
|
|
441
|
+
// because it was only ever visible in a log nobody had enabled (issue #128).
|
|
442
|
+
const unstorable = new UnstorableTracker();
|
|
443
|
+
const noteUnstorable = (path) => unstorable.note(path);
|
|
397
444
|
// Takes only the mode: the patterns themselves live in pathFilterMatcher,
|
|
398
445
|
// compiled from this same config at start. Accepting a `paths` array here
|
|
399
446
|
// would imply it is consulted when it is not.
|
|
@@ -600,19 +647,34 @@ export default (app) => {
|
|
|
600
647
|
},
|
|
601
648
|
};
|
|
602
649
|
if (config.managedContainer !== false) {
|
|
603
|
-
|
|
650
|
+
// Two phases in one call: poll for the manager global, then wait for
|
|
651
|
+
// runtime detection to settle. The previous code read the global ONCE,
|
|
652
|
+
// synchronously — which worked only because plugins start in
|
|
653
|
+
// alphabetical order and "signalk-questdb" sorts after
|
|
654
|
+
// "signalk-container". Any rename, or a slower container start, and the
|
|
655
|
+
// plugin reported "signalk-container required" on a system that has it.
|
|
656
|
+
//
|
|
657
|
+
// The signal is threaded through so a purge preempting a start stops
|
|
658
|
+
// the poll immediately rather than waiting out the full budget.
|
|
659
|
+
app.setPluginStatus("Waiting for signalk-container plugin...");
|
|
660
|
+
const { manager: containers, runtime } = await waitForContainerManager({
|
|
661
|
+
timeoutMs: CONTAINER_MANAGER_TIMEOUT_MS,
|
|
662
|
+
signal,
|
|
663
|
+
onWaiting: (phase) => {
|
|
664
|
+
if (phase === "runtime")
|
|
665
|
+
app.setPluginStatus("Waiting for container runtime detection...");
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
// Purge may have preempted us while the wait was pending; bail before
|
|
669
|
+
// touching the runtime so we don't recreate what purge is removing.
|
|
670
|
+
if (signal.aborted)
|
|
671
|
+
return;
|
|
604
672
|
if (!containers) {
|
|
605
673
|
app.debug("containerManager not found");
|
|
606
674
|
publishError("signalk-container plugin required for managed mode. Install it or set managedContainer=false.");
|
|
607
675
|
return;
|
|
608
676
|
}
|
|
609
|
-
|
|
610
|
-
await containers.whenReady();
|
|
611
|
-
// Purge may have preempted us while whenReady() was pending; bail before
|
|
612
|
-
// touching the runtime so we don't recreate what purge is removing.
|
|
613
|
-
if (signal.aborted)
|
|
614
|
-
return;
|
|
615
|
-
if (!containers.getRuntime()) {
|
|
677
|
+
if (!runtime) {
|
|
616
678
|
app.debug("container runtime not detected");
|
|
617
679
|
publishError("No container runtime detected. Check signalk-container plugin.");
|
|
618
680
|
return;
|
|
@@ -812,13 +874,32 @@ export default (app) => {
|
|
|
812
874
|
return;
|
|
813
875
|
if (!isSelf && !config.recordOthers)
|
|
814
876
|
return;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
877
|
+
// Routed once, before the gates, because object values are gated on
|
|
878
|
+
// their leaf paths instead — see the "flatten" branch below. Applying
|
|
879
|
+
// the parent's gates to those would be wrong twice over: an
|
|
880
|
+
// include-filter listing only `navigation.attitude.roll` would drop the
|
|
881
|
+
// parent before any leaf was seen, and the parent would consume a
|
|
882
|
+
// throttle slot the leaves then have to wait out again, halving the
|
|
883
|
+
// effective sampling rate.
|
|
884
|
+
const route = routeDeltaValue(path, value);
|
|
885
|
+
// Recorded BEFORE the gates. A value nothing can store is worth
|
|
886
|
+
// reporting whether or not the user also filters or throttles that
|
|
887
|
+
// path — and gating it first would hide exactly the case this exists to
|
|
888
|
+
// surface, since a throttled array would return here and never be
|
|
889
|
+
// counted.
|
|
890
|
+
if (route === null) {
|
|
891
|
+
noteUnstorable(path);
|
|
821
892
|
return;
|
|
893
|
+
}
|
|
894
|
+
if (route !== "flatten") {
|
|
895
|
+
if (!shouldRecord(path, config.pathFilter.mode))
|
|
896
|
+
return;
|
|
897
|
+
// Throttled per path AND context: the sampling rate bounds each
|
|
898
|
+
// vessel's stream, not the fleet's (issue #93). The stored context
|
|
899
|
+
// ("self" vs raw) is the key, matching what the rows carry.
|
|
900
|
+
if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000))
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
822
903
|
// Rows are stamped with the server receive time, deliberately NOT the
|
|
823
904
|
// delta's own timestamp: a boat is a set of independent clocks (GPS
|
|
824
905
|
// time, RTC-less devices, gateway latencies), and storing per-source
|
|
@@ -832,7 +913,6 @@ export default (app) => {
|
|
|
832
913
|
// even gets *more* accurate history. Re-sent ILP batches keep their
|
|
833
914
|
// original stamps (baked at write() time), so replay-idempotency holds.
|
|
834
915
|
const ctx = isSelf ? "self" : context;
|
|
835
|
-
const route = routeDeltaValue(path, value);
|
|
836
916
|
if (route === "number") {
|
|
837
917
|
writer.write(path, ctx, value);
|
|
838
918
|
}
|
|
@@ -848,6 +928,40 @@ export default (app) => {
|
|
|
848
928
|
else if (route === "position") {
|
|
849
929
|
writer.writePosition(ctx, value);
|
|
850
930
|
}
|
|
931
|
+
else if (route === "flatten") {
|
|
932
|
+
// Object values are recorded as their scalar leaves (issue #128).
|
|
933
|
+
const { leaves, skipped } = flattenObjectValue(path, value);
|
|
934
|
+
// Only the leaves are reported, never the parent as well. Each
|
|
935
|
+
// skipped leaf is already named here, so also noting the parent
|
|
936
|
+
// double-counts one problem and — worse — burns a second slot against
|
|
937
|
+
// the cap, hitting it sooner and hiding genuinely distinct paths.
|
|
938
|
+
//
|
|
939
|
+
// An object with NO storable leaf still gets reported: `skipped` then
|
|
940
|
+
// holds every key, so the drop is visible through them. The only
|
|
941
|
+
// silent case left is an empty object, which carries no data to lose.
|
|
942
|
+
for (const leafPath of skipped)
|
|
943
|
+
noteUnstorable(leafPath);
|
|
944
|
+
for (const leaf of leaves) {
|
|
945
|
+
// Filtered and throttled on the LEAF path, not the parent: the
|
|
946
|
+
// leaves are what the history API exposes, so excluding
|
|
947
|
+
// `navigation.attitude.yaw` has to actually exclude it. Throttling
|
|
948
|
+
// per leaf also keeps one object's sampling budget from being
|
|
949
|
+
// consumed by whichever key happened to be written first.
|
|
950
|
+
if (!shouldRecord(leaf.path, config.pathFilter.mode))
|
|
951
|
+
continue;
|
|
952
|
+
if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000))
|
|
953
|
+
continue;
|
|
954
|
+
if (typeof leaf.value === "number") {
|
|
955
|
+
writer.write(leaf.path, ctx, leaf.value);
|
|
956
|
+
}
|
|
957
|
+
else if (typeof leaf.value === "boolean") {
|
|
958
|
+
writer.writeString(leaf.path, ctx, leaf.value ? "true" : "false", undefined, "boolean");
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
writer.writeString(leaf.path, ctx, leaf.value);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
851
965
|
});
|
|
852
966
|
unsubscribes.push(unsub);
|
|
853
967
|
if (config.retentionDays && config.retentionDays > 0) {
|
|
@@ -1044,6 +1158,9 @@ export default (app) => {
|
|
|
1044
1158
|
// restore count from the previous run.
|
|
1045
1159
|
restoreSummary = null;
|
|
1046
1160
|
restoreStatus = null;
|
|
1161
|
+
// Same reason: a path that is unstorable under this config may not be
|
|
1162
|
+
// under the next, so the count must not carry across a restart.
|
|
1163
|
+
unstorable.clear();
|
|
1047
1164
|
pluginErrorActive = false;
|
|
1048
1165
|
for (const unsub of unsubscribes) {
|
|
1049
1166
|
try {
|
|
@@ -1245,6 +1362,17 @@ export default (app) => {
|
|
|
1245
1362
|
? `${questdbEndpoints.http.host}:${questdbEndpoints.http.port}`
|
|
1246
1363
|
: null,
|
|
1247
1364
|
restore: restoreStatus,
|
|
1365
|
+
// Omitted entirely when nothing was dropped, so the panel can
|
|
1366
|
+
// treat presence as "there is something to report".
|
|
1367
|
+
unstorable: unstorable.size > 0
|
|
1368
|
+
? {
|
|
1369
|
+
paths: unstorable.size,
|
|
1370
|
+
truncated: unstorable.truncated,
|
|
1371
|
+
// A handful is enough to identify the shape; the tracked
|
|
1372
|
+
// set is capped but still larger than a status body wants.
|
|
1373
|
+
examples: unstorable.examples(5),
|
|
1374
|
+
}
|
|
1375
|
+
: null,
|
|
1248
1376
|
});
|
|
1249
1377
|
}
|
|
1250
1378
|
catch (err) {
|