signalk-questdb 1.8.1 → 1.8.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 CHANGED
@@ -201,6 +201,8 @@ with no position in the window is skipped, as is one that has already transmitte
201
201
  since startup. Restore honours the recording toggles: with **Record AIS targets**
202
202
  off, no AIS target is restored.
203
203
 
204
+ The config panel reports the outcome once startup finishes: **Vessels Restored** with the count, or **Restore failed** if the replay could not run. Neither appears when the option is off.
205
+
204
206
  ## Connectivity
205
207
 
206
208
  In **managed mode** the plugin no longer needs `QuestDB host` to be correct for
@@ -43,6 +43,23 @@ export interface DbStatus extends ApiError {
43
43
  ulimitClamp?: UlimitClampStatus | null;
44
44
  hostMaxMapCount?: MaxMapCountStatus | null;
45
45
  endpoint?: string | null;
46
+ /**
47
+ * Outcome of the startup restore, or null when it did not run (disabled, or
48
+ * still in flight). Surfaced here rather than only through
49
+ * `setPluginStatus`, because Signal K stores plugin status under
50
+ * `plugin.id` but reads it back by `plugin.name` — so any plugin with a
51
+ * display name gets an empty statusMessage and the count would be invisible.
52
+ */
53
+ restore?: RestoreStatus | null;
54
+ }
55
+ /** Startup-restore outcome, shown on the config panel. */
56
+ export interface RestoreStatus {
57
+ /** Vessels replayed into the Signal K model. */
58
+ contexts: number;
59
+ /** Contexts skipped because the vessel had already transmitted. */
60
+ skippedLive: number;
61
+ /** True when the restore threw; `contexts` is then not meaningful. */
62
+ failed: boolean;
46
63
  }
47
64
  /** GET /api/versions — QuestDB releases, drafts filtered out. */
48
65
  export interface QuestdbVersion {
@@ -29,5 +29,13 @@ export interface ConsoleProxyDeps {
29
29
  /** Base URL of QuestDB's HTTP interface, e.g. `http://127.0.0.1:9000`. */
30
30
  baseUrl: () => string | null;
31
31
  debug: (msg: string) => void;
32
+ /**
33
+ * Where this proxy is mounted, e.g. `/plugins/signalk-questdb/console`.
34
+ * Needed to rewrite upstream redirects: QuestDB answers `/` with
35
+ * `301 Location: /index.html`, an ABSOLUTE path that escapes the mount and
36
+ * lands on the Signal K root — a 404, and the reason the webapp reported
37
+ * "console unavailable" while the console itself was fine.
38
+ */
39
+ mountPath: string;
32
40
  }
33
41
  export declare function createConsoleProxy(deps: ConsoleProxyDeps): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,4 +1,18 @@
1
1
  import http from "node:http";
2
+ /**
3
+ * Rewrite an upstream redirect so it stays inside the mount.
4
+ *
5
+ * Only root-relative targets need it: a relative `Location` already resolves
6
+ * against the proxied URL, and an absolute one pointing elsewhere is not ours
7
+ * to rewrite (and is left alone rather than silently retargeted).
8
+ */
9
+ function rewriteLocation(location, mountPath) {
10
+ if (typeof location !== "string")
11
+ return undefined;
12
+ if (!location.startsWith("/") || location.startsWith("//"))
13
+ return undefined;
14
+ return `${mountPath.replace(/\/$/, "")}${location}`;
15
+ }
2
16
  // Hop-by-hop headers must not be forwarded (RFC 7230 §6.1). `host` is dropped
3
17
  // separately because it has to name the upstream, not the Signal K server.
4
18
  const HOP_BY_HOP = new Set([
@@ -102,6 +116,9 @@ export function createConsoleProxy(deps) {
102
116
  headers: forwardableHeaders(req.headers),
103
117
  }, (proxied) => {
104
118
  const headers = returnableHeaders(proxied.headers);
119
+ const rewritten = rewriteLocation(proxied.headers.location, deps.mountPath);
120
+ if (rewritten)
121
+ headers.location = rewritten;
105
122
  // The console is served under the Signal K origin, so its scripts run
106
123
  // with the admin's session. Signal K's auth cookie is httpOnly, so
107
124
  // script cannot read it — but it IS attached to same-origin requests,
@@ -125,18 +142,74 @@ export function createConsoleProxy(deps) {
125
142
  "form-action 'self'; frame-ancestors 'self'; base-uri 'self'";
126
143
  // The console has no reason to be sniffed into a different type.
127
144
  headers["x-content-type-options"] = "nosniff";
128
- res.writeHead(proxied.statusCode ?? 502, headers);
129
- // A HEAD response carries no body by definition. QuestDB nonetheless
130
- // answers HEAD with `405 Transfer-Encoding: chunked` AND a body, which
131
- // is protocol-illegal; piping that upstream response through leaves
132
- // the client waiting for chunks that never legally arrive and the
133
- // connection dies with an empty reply. Terminate the response
134
- // ourselves and discard whatever the upstream sent.
135
- if (req.method === "HEAD") {
145
+ // QuestDB's redirect states NEITHER content-length nor
146
+ // transfer-encoding, so Node cannot know the response is complete and
147
+ // falls back to `Transfer-Encoding: chunked` — announcing a body that
148
+ // never arrives. The browser then waits on a 301 that never finishes,
149
+ // which is exactly how a working console still reported "console
150
+ // unavailable" after the Location rewrite was already correct.
151
+ //
152
+ // Declaring the length explicitly is what stops Node guessing. Applied
153
+ // to every status that carries no body by definition, not just 3xx.
154
+ // Deliberately keyed on the STATUS, not on the upstream's framing
155
+ // headers: an origin that omits both (QuestDB) and one that sends
156
+ // `chunked` for an empty body (Node's own server does this) must be
157
+ // handled identically, and only the status tells us a body is absent
158
+ // by definition.
159
+ const status = proxied.statusCode;
160
+ // 204 and 304 carry no body by protocol, whatever the origin sends.
161
+ // Node already frames these correctly, so they only need the upstream
162
+ // drained.
163
+ const neverHasBody = status === 204 || status === 304;
164
+ // A 3xx MAY carry a body (RFC 9110 §15.4) and browsers render it, so
165
+ // this cannot assume every redirect is empty — an earlier version did,
166
+ // and silently dropped the body.
167
+ //
168
+ // Bodyless means the upstream declared NEITHER framing header. Testing
169
+ // content-length alone is not enough: a redirect body sent with
170
+ // chunked encoding carries no length, and was also being dropped.
171
+ const declaresBody = proxied.headers["content-length"] !== undefined ||
172
+ proxied.headers["transfer-encoding"] !== undefined;
173
+ const redirectWithoutBody = status !== undefined &&
174
+ status >= 300 &&
175
+ status < 400 &&
176
+ !declaresBody;
177
+ if (req.method === "HEAD" || neverHasBody || redirectWithoutBody) {
178
+ // Synthesize `content-length: 0` only when the upstream declared no
179
+ // framing at all — that is the case where Node would otherwise guess
180
+ // and fall back to chunked for a body that never arrives.
181
+ //
182
+ // Do NOT synthesize it when the upstream stated a length. A HEAD
183
+ // response must carry the same headers a GET would (RFC 9110
184
+ // §9.3.2), so overwriting a real `content-length: 12345` with 0
185
+ // misreports the resource's size to the client.
186
+ //
187
+ // 204/304 must not carry content-length at all (RFC 9110 §6.4.1).
188
+ if (!neverHasBody && !declaresBody) {
189
+ headers["content-length"] = "0";
190
+ }
191
+ res.writeHead(status ?? 502, headers);
192
+ // Drain whatever the upstream sent regardless: QuestDB answers HEAD
193
+ // with a body under chunked framing, which is protocol-illegal, and
194
+ // leaving it unread holds the socket open.
136
195
  proxied.resume();
137
196
  res.end();
138
197
  return;
139
198
  }
199
+ res.writeHead(status ?? 502, headers);
200
+ // If the upstream dies mid-body, `pipe` alone never ends `res` — the
201
+ // browser sits on a response that will never complete. Node signals
202
+ // truncation to a direct client by aborting the socket; destroying our
203
+ // response does the same for ours, so a truncated console query fails
204
+ // visibly instead of hanging the tab.
205
+ proxied.on("aborted", () => {
206
+ deps.debug("console proxy: upstream aborted mid-response");
207
+ res.destroy();
208
+ });
209
+ proxied.on("error", (err) => {
210
+ deps.debug(`console proxy: upstream stream error: ${err.message}`);
211
+ res.destroy();
212
+ });
140
213
  proxied.pipe(res);
141
214
  });
142
215
  upstream.on("error", (err) => {
@@ -1 +1 @@
1
- {"version":3,"file":"console-proxy.js","sourceRoot":"","sources":["../src/console-proxy.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"console-proxy.js","sourceRoot":"","sources":["../src/console-proxy.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -137,6 +137,33 @@ export default (app) => {
137
137
  // (health-wait exhausted, ensureTables/connect threw) leaves them non-null
138
138
  // on a plugin that never came up.
139
139
  let pluginRunning = false;
140
+ // Summary of the startup restore, appended to the plugin status so the
141
+ // result is visible on the plugin card. Without this the only report was an
142
+ // app.debug() line, invisible unless DEBUG is set — so a user had no way to
143
+ // tell whether a feature whose entire point is "did it repopulate?" had run.
144
+ let restoreSummary = null;
145
+ // Structured form of the same result, served on /api/status. The status
146
+ // string above is kept because it is correct and will start working if
147
+ // Signal K's plugin.id/plugin.name status mismatch is ever fixed, but the
148
+ // config panel reads THIS — it is the only path that is visible today.
149
+ let restoreStatus = null;
150
+ const recordingStatus = (host, port) => `Recording to QuestDB at ${host}:${port}` +
151
+ (restoreSummary ? ` — ${restoreSummary}` : "");
152
+ // True while a writer/WAL failure is on the status line. Signal K keeps ONE
153
+ // status entry per plugin, so publishing "Recording…" over an active error
154
+ // hides it — and the restore result, which lands seconds after startup on a
155
+ // timer of its own, is exactly the kind of late writer that would do it.
156
+ let pluginErrorActive = false;
157
+ const publishError = (msg) => {
158
+ pluginErrorActive = true;
159
+ app.setPluginError(msg);
160
+ };
161
+ // Republish the recording line, but never at the cost of an active error.
162
+ const publishRecordingStatus = (host, port) => {
163
+ if (pluginErrorActive)
164
+ return;
165
+ app.setPluginStatus(recordingStatus(host, port));
166
+ };
140
167
  // Contexts seen live while a startup restore is in flight. A stored fix
141
168
  // must never overwrite a vessel that has already transmitted since boot —
142
169
  // that would move it backwards on the chart. Populated by the recorder
@@ -249,6 +276,7 @@ export default (app) => {
249
276
  const consoleProxy = createConsoleProxy({
250
277
  baseUrl: () => (currentConfig ? questdbHttpBaseUrl() : null),
251
278
  debug: (msg) => app.debug(msg),
279
+ mountPath: "/plugins/signalk-questdb/console",
252
280
  });
253
281
  /**
254
282
  * Compute the bind-mount source for QuestDB's /var/lib/questdb volume.
@@ -575,7 +603,7 @@ export default (app) => {
575
603
  const containers = globalThis.__signalk_containerManager;
576
604
  if (!containers) {
577
605
  app.debug("containerManager not found");
578
- app.setPluginError("signalk-container plugin required for managed mode. Install it or set managedContainer=false.");
606
+ publishError("signalk-container plugin required for managed mode. Install it or set managedContainer=false.");
579
607
  return;
580
608
  }
581
609
  app.setPluginStatus("Waiting for container runtime detection...");
@@ -586,7 +614,7 @@ export default (app) => {
586
614
  return;
587
615
  if (!containers.getRuntime()) {
588
616
  app.debug("container runtime not detected");
589
- app.setPluginError("No container runtime detected. Check signalk-container plugin.");
617
+ publishError("No container runtime detected. Check signalk-container plugin.");
590
618
  return;
591
619
  }
592
620
  void (async () => {
@@ -643,7 +671,7 @@ export default (app) => {
643
671
  }
644
672
  catch (err) {
645
673
  app.debug("ensureRunning failed:", err);
646
- app.setPluginError(`Failed to start QuestDB container: ${err instanceof Error ? err.message : String(err)}`);
674
+ publishError(`Failed to start QuestDB container: ${err instanceof Error ? err.message : String(err)}`);
647
675
  return;
648
676
  }
649
677
  }
@@ -667,7 +695,7 @@ export default (app) => {
667
695
  return;
668
696
  }
669
697
  if (!(await queryClient.isHealthy())) {
670
- app.setPluginError(`QuestDB not responding at ${httpHost}:${httpPort}`);
698
+ publishError(`QuestDB not responding at ${httpHost}:${httpPort}`);
671
699
  return;
672
700
  }
673
701
  app.setPluginStatus("Creating tables...");
@@ -679,8 +707,13 @@ export default (app) => {
679
707
  writer = new ILPWriter(ilpHost, ilpPort, (msg) => app.debug(msg), {
680
708
  // Surface a flapping ILP connection instead of leaving the status line
681
709
  // stuck on a cheerful "Recording" while every sample is dropped.
682
- onUnhealthy: (msg) => app.setPluginError(msg),
683
- onHealthy: () => app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`),
710
+ onUnhealthy: (msg) => publishError(msg),
711
+ onHealthy: () => {
712
+ // The writer recovered: the error is no longer current, so the
713
+ // recording line may take the status entry back.
714
+ pluginErrorActive = false;
715
+ app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
716
+ },
684
717
  flushIntervalMs: config.ilpFlushIntervalMs,
685
718
  });
686
719
  await writer.connect();
@@ -846,7 +879,7 @@ export default (app) => {
846
879
  const summary = tables
847
880
  .map((t) => `${t.name} (${t.txnLag} txns behind)`)
848
881
  .join(", ");
849
- app.setPluginError(`QuestDB WAL suspended: ${summary}`);
882
+ publishError(`QuestDB WAL suspended: ${summary}`);
850
883
  const key = "alert:" +
851
884
  tables
852
885
  .map((t) => `${t.name}@${t.writerTxn}`)
@@ -862,7 +895,7 @@ export default (app) => {
862
895
  },
863
896
  onResolved: () => {
864
897
  emitWalNotification("normal", "QuestDB history recording has recovered.", "normal:recovered");
865
- app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
898
+ app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
866
899
  },
867
900
  debug: (msg) => app.debug(msg),
868
901
  error: (msg) => app.error(msg),
@@ -886,7 +919,7 @@ export default (app) => {
886
919
  `/etc/sysctl.d/99-questdb.conf && sudo sysctl --system`);
887
920
  }
888
921
  }
889
- app.setPluginStatus(`Recording to QuestDB at ${ilpHost}:${ilpPort}`);
922
+ app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
890
923
  // Everything is registered — only now does the plugin count as running.
891
924
  // Any earlier return/throw leaves the flag false, so a half-started
892
925
  // plugin rejects lifecycle-dependent requests like /api/update/apply.
@@ -919,11 +952,41 @@ export default (app) => {
919
952
  maxAgeMs: config.restoreMaxAgeMinutes * 60_000,
920
953
  restoreSelf: config.recordSelf,
921
954
  restoreOthers: config.recordOthers,
955
+ })
956
+ .then((result) => {
957
+ if (lifecycleGeneration !== restoreGeneration)
958
+ return;
959
+ restoreSummary =
960
+ result.contexts > 0
961
+ ? `restored ${result.contexts} vessel${result.contexts === 1 ? "" : "s"} at startup`
962
+ : "no vessels to restore at startup";
963
+ restoreStatus = {
964
+ contexts: result.contexts,
965
+ skippedLive: result.skippedLive,
966
+ failed: false,
967
+ };
968
+ // Re-publish so the summary lands on the card even when the status
969
+ // was already set before the (slow) restore query finished.
970
+ if (questdbEndpoints) {
971
+ const { host, port } = questdbEndpoints.ilp;
972
+ publishRecordingStatus(host, port);
973
+ }
922
974
  })
923
975
  .catch((err) => {
924
976
  // A failed restore is a cosmetic loss — targets still arrive live —
925
- // so it must never take the plugin down with it.
926
- app.debug(`restore failed: ${err instanceof Error ? err.message : String(err)}`);
977
+ // so it must never take the plugin down with it. Surfaced on the
978
+ // card rather than only in debug: silence is indistinguishable from
979
+ // "restore is off".
980
+ const message = err instanceof Error ? err.message : String(err);
981
+ app.debug(`restore failed: ${message}`);
982
+ if (lifecycleGeneration !== restoreGeneration)
983
+ return;
984
+ restoreSummary = "startup restore failed";
985
+ restoreStatus = { contexts: 0, skippedLive: 0, failed: true };
986
+ if (questdbEndpoints) {
987
+ const { host, port } = questdbEndpoints.ilp;
988
+ publishRecordingStatus(host, port);
989
+ }
927
990
  })
928
991
  .finally(() => {
929
992
  // Only needed to shield the restore; tracking every context for the
@@ -960,7 +1023,7 @@ export default (app) => {
960
1023
  // Server does not await start(), so run async init in a
961
1024
  // self-contained promise that handles its own errors.
962
1025
  asyncStart(config).catch((err) => {
963
- app.setPluginError(`Startup failed: ${err instanceof Error ? err.message : String(err)}`);
1026
+ publishError(`Startup failed: ${err instanceof Error ? err.message : String(err)}`);
964
1027
  });
965
1028
  },
966
1029
  async stop() {
@@ -977,6 +1040,11 @@ export default (app) => {
977
1040
  // armed, accumulating contexts for a restore that never happens.
978
1041
  trackLiveContexts = false;
979
1042
  liveContexts.clear();
1043
+ // Stale across a restart otherwise: the card would still claim a
1044
+ // restore count from the previous run.
1045
+ restoreSummary = null;
1046
+ restoreStatus = null;
1047
+ pluginErrorActive = false;
980
1048
  for (const unsub of unsubscribes) {
981
1049
  try {
982
1050
  unsub();
@@ -1176,6 +1244,7 @@ export default (app) => {
1176
1244
  endpoint: questdbEndpoints
1177
1245
  ? `${questdbEndpoints.http.host}:${questdbEndpoints.http.port}`
1178
1246
  : null,
1247
+ restore: restoreStatus,
1179
1248
  });
1180
1249
  }
1181
1250
  catch (err) {