signalk-questdb 2.0.1 → 2.1.1
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/.coderabbit.yaml +4 -3
- package/README.md +151 -5
- package/dist/api-contract.d.ts +62 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/configpanel/responses.d.ts +33 -1
- package/dist/configpanel/responses.js +89 -0
- package/dist/configpanel/responses.js.map +1 -1
- package/dist/history-v1.js +12 -9
- package/dist/history-v1.js.map +1 -1
- package/dist/history-v2.d.ts +18 -6
- package/dist/history-v2.js +275 -41
- package/dist/history-v2.js.map +1 -1
- package/dist/ilp-writer.d.ts +22 -0
- package/dist/ilp-writer.js +76 -4
- package/dist/ilp-writer.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +326 -58
- package/dist/index.js.map +1 -1
- package/dist/influx-detect.d.ts +19 -0
- package/dist/influx-detect.js +165 -0
- package/dist/influx-detect.js.map +1 -0
- package/dist/migration.d.ts +147 -0
- package/dist/migration.js +819 -0
- package/dist/migration.js.map +1 -0
- package/dist/query-client.d.ts +9 -0
- package/dist/query-client.js +11 -0
- package/dist/query-client.js.map +1 -1
- package/dist/restore.js +9 -0
- package/dist/restore.js.map +1 -1
- package/dist/schema-errors.d.ts +15 -0
- package/dist/schema-errors.js +38 -0
- package/dist/schema-errors.js.map +1 -0
- package/package.json +5 -4
- package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +9 -0
- package/public/assets/{index-Z7eUk0W6.js → index-CyI4zKUm.js} +1 -1
- package/public/assets/{virtualExposes-CyrNon4j.js → virtualExposes-BBIkbTtD.js} +1 -1
- package/public/remoteEntry.js +1 -1
- package/dist/test/config.test.d.ts +0 -1
- package/dist/test/config.test.js +0 -161
- package/dist/test/config.test.js.map +0 -1
- package/dist/test/configpanel-responses.test.d.ts +0 -1
- package/dist/test/configpanel-responses.test.js +0 -108
- package/dist/test/configpanel-responses.test.js.map +0 -1
- package/dist/test/console-proxy.test.d.ts +0 -1
- package/dist/test/console-proxy.test.js +0 -664
- package/dist/test/console-proxy.test.js.map +0 -1
- package/dist/test/container-env.test.d.ts +0 -1
- package/dist/test/container-env.test.js +0 -70
- package/dist/test/container-env.test.js.map +0 -1
- package/dist/test/delta-routing.test.d.ts +0 -1
- package/dist/test/delta-routing.test.js +0 -258
- package/dist/test/delta-routing.test.js.map +0 -1
- package/dist/test/full-export-range.test.d.ts +0 -1
- package/dist/test/full-export-range.test.js +0 -78
- package/dist/test/full-export-range.test.js.map +0 -1
- package/dist/test/history-v1.test.d.ts +0 -1
- package/dist/test/history-v1.test.js +0 -676
- package/dist/test/history-v1.test.js.map +0 -1
- package/dist/test/history-v2.test.d.ts +0 -1
- package/dist/test/history-v2.test.js +0 -563
- package/dist/test/history-v2.test.js.map +0 -1
- package/dist/test/host-limits.test.d.ts +0 -1
- package/dist/test/host-limits.test.js +0 -81
- package/dist/test/host-limits.test.js.map +0 -1
- package/dist/test/ilp-writer.test.d.ts +0 -1
- package/dist/test/ilp-writer.test.js +0 -391
- package/dist/test/ilp-writer.test.js.map +0 -1
- package/dist/test/package-e2e.test.d.ts +0 -1
- package/dist/test/package-e2e.test.js +0 -306
- package/dist/test/package-e2e.test.js.map +0 -1
- package/dist/test/path-matcher.test.d.ts +0 -1
- package/dist/test/path-matcher.test.js +0 -224
- package/dist/test/path-matcher.test.js.map +0 -1
- package/dist/test/query-client.test.d.ts +0 -1
- package/dist/test/query-client.test.js +0 -299
- package/dist/test/query-client.test.js.map +0 -1
- package/dist/test/questdb-endpoint.test.d.ts +0 -1
- package/dist/test/questdb-endpoint.test.js +0 -297
- package/dist/test/questdb-endpoint.test.js.map +0 -1
- package/dist/test/restore.test.d.ts +0 -1
- package/dist/test/restore.test.js +0 -496
- package/dist/test/restore.test.js.map +0 -1
- package/dist/test/time-range.test.d.ts +0 -1
- package/dist/test/time-range.test.js +0 -53
- package/dist/test/time-range.test.js.map +0 -1
- package/dist/test/wal-monitor.test.d.ts +0 -1
- package/dist/test/wal-monitor.test.js +0 -405
- package/dist/test/wal-monitor.test.js.map +0 -1
- package/public/assets/PluginConfigurationPanel-CyS3A7gD.js +0 -9
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { startRetention } from "./retention.js";
|
|
|
9
9
|
import { RESTORE_SOURCE, restoreFromHistory } from "./restore.js";
|
|
10
10
|
import { createConsoleProxy } from "./console-proxy.js";
|
|
11
11
|
import { buildFullExportWhere } from "./full-export-range.js";
|
|
12
|
+
import { detectInflux, validateInfluxUrl } from "./influx-detect.js";
|
|
13
|
+
import { listBuckets, listMeasurements, runMigration, MigrationRun, } from "./migration.js";
|
|
12
14
|
import { WalMonitor, buildPendingSegmentsSQL, computeSkipPlan, extractApplyError, isPartitionOpenFailure, skipPlansEqual, } from "./wal-monitor.js";
|
|
13
15
|
import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL_PG_PORT, QUESTDB_ACCESSIBLE_PORTS, resolveManagedEndpoints, resolveLanExposureHost, lanExposureEndpoints, } from "./questdb-endpoint.js";
|
|
14
16
|
import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
|
|
@@ -94,6 +96,13 @@ function buildResourceLimits(config) {
|
|
|
94
96
|
}
|
|
95
97
|
export default (app) => {
|
|
96
98
|
let writer = null;
|
|
99
|
+
// The most recent (or currently running) InfluxDB import. Deliberately
|
|
100
|
+
// in-memory and single-slot: an import is an operator-driven one-off, and
|
|
101
|
+
// surviving a plugin restart would mean persisting a half-finished run with
|
|
102
|
+
// no writer to resume it onto. A restart therefore forgets it, and re-running
|
|
103
|
+
// is safe because imported rows upsert on (ts, path, context, source).
|
|
104
|
+
let activeMigration = null;
|
|
105
|
+
let migrationCounter = 0;
|
|
97
106
|
let queryClient = null;
|
|
98
107
|
let retentionTimer = null;
|
|
99
108
|
let schemaHealTimer = null;
|
|
@@ -107,6 +116,12 @@ export default (app) => {
|
|
|
107
116
|
// capped QuestDB's requested value. Null until a clamp happens (or on a
|
|
108
117
|
// signalk-container older than 1.18.0, which doesn't emit the event).
|
|
109
118
|
let ulimitClamp = null;
|
|
119
|
+
// Set by onContainerWedged: the runtime couldn't stop/remove the container to
|
|
120
|
+
// apply a change, so signalk-container deferred the recreate (it does NOT
|
|
121
|
+
// throw). Sticky within a start/update cycle — the container is running the
|
|
122
|
+
// OLD config/version, so we must not report success or persist a new version
|
|
123
|
+
// as if the change applied. Cleared at the top of each ensureRunning attempt.
|
|
124
|
+
let containerWedged = null;
|
|
110
125
|
// Whether this QuestDB's wal_tables() exposes the errorTag/errorMessage
|
|
111
126
|
// columns (present on current builds, absent on an older pinned version).
|
|
112
127
|
// Probed once via the richer status query and cached so an old build does
|
|
@@ -211,6 +226,18 @@ export default (app) => {
|
|
|
211
226
|
ulimitClamp = event;
|
|
212
227
|
app.debug(event.reason);
|
|
213
228
|
};
|
|
229
|
+
// A wedged container (the runtime can't stop/remove it — usually an orphaned
|
|
230
|
+
// rootless-Podman user namespace after a Podman service restart) cannot be
|
|
231
|
+
// recovered from the plugin; signalk-container defers the recreate and fires
|
|
232
|
+
// this once. Surface the remedy as a plugin error rather than letting the
|
|
233
|
+
// operator see only a silent drift loop in the server log. Requires
|
|
234
|
+
// signalk-container >= 1.29.0; on older versions the callback never fires.
|
|
235
|
+
const onContainerWedged = (event) => {
|
|
236
|
+
containerWedged = event.reason;
|
|
237
|
+
// publishError → setPluginError already lands in the server log at error
|
|
238
|
+
// level and drives the admin-UI status; matches every other error site.
|
|
239
|
+
publishError(event.reason);
|
|
240
|
+
};
|
|
214
241
|
// Tables the plugin owns; each is rebuilt if ILP auto-created it with the
|
|
215
242
|
// wrong designated-timestamp schema. (signalk_position uses `ts` too.)
|
|
216
243
|
const OWNED_TABLES = ["signalk", "signalk_str", "signalk_position"];
|
|
@@ -714,8 +741,10 @@ export default (app) => {
|
|
|
714
741
|
// limit was raised) doesn't leave a stale warning; onUlimitClamped
|
|
715
742
|
// re-sets it if this run clamps again.
|
|
716
743
|
ulimitClamp = null;
|
|
744
|
+
containerWedged = null;
|
|
717
745
|
await containers.ensureRunning(QUESTDB_CONTAINER_NAME, containerConfig, {
|
|
718
746
|
onUlimitClamped,
|
|
747
|
+
onContainerWedged,
|
|
719
748
|
});
|
|
720
749
|
// ensureRunning() just (re)created the container. If purge preempted us
|
|
721
750
|
// while it ran, return now so we don't proceed to resolve endpoints and
|
|
@@ -795,7 +824,7 @@ export default (app) => {
|
|
|
795
824
|
queryClient = null;
|
|
796
825
|
return;
|
|
797
826
|
}
|
|
798
|
-
const v2Provider = createHistoryProviderV2(queryClient, app.selfContext);
|
|
827
|
+
const v2Provider = createHistoryProviderV2(queryClient, app.selfContext, config.historySourcePolicyAll ?? false, (msg) => app.debug(msg));
|
|
799
828
|
app.registerHistoryApiProvider(v2Provider);
|
|
800
829
|
const v1Provider = createHistoryProviderV1(queryClient, app.selfContext, (msg) => app.debug(msg));
|
|
801
830
|
app.registerHistoryProvider(v1Provider);
|
|
@@ -1034,7 +1063,14 @@ export default (app) => {
|
|
|
1034
1063
|
`/etc/sysctl.d/99-questdb.conf && sudo sysctl --system`);
|
|
1035
1064
|
}
|
|
1036
1065
|
}
|
|
1037
|
-
|
|
1066
|
+
// If the container wedged, ensureRunning deferred rather than threw, and the
|
|
1067
|
+
// "Waiting…/Creating tables…" progress lines above have since overwritten
|
|
1068
|
+
// the error onContainerWedged set. Re-assert it as the final status so the
|
|
1069
|
+
// operator sees the remedy, not a false "Recording". publishRecordingStatus
|
|
1070
|
+
// is guarded on pluginErrorActive, so it is a no-op while the error stands.
|
|
1071
|
+
if (containerWedged)
|
|
1072
|
+
publishError(containerWedged);
|
|
1073
|
+
publishRecordingStatus(ilpHost, ilpPort);
|
|
1038
1074
|
// Everything is registered — only now does the plugin count as running.
|
|
1039
1075
|
// Any earlier return/throw leaves the flag false, so a half-started
|
|
1040
1076
|
// plugin rejects lifecycle-dependent requests like /api/update/apply.
|
|
@@ -1189,6 +1225,14 @@ export default (app) => {
|
|
|
1189
1225
|
// Clear the clamp warning so it doesn't survive into the next start
|
|
1190
1226
|
// (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
|
|
1191
1227
|
ulimitClamp = null;
|
|
1228
|
+
// Stop any import before the writer goes away: runMigration holds a
|
|
1229
|
+
// direct reference to it, so a run left going would keep enqueueing
|
|
1230
|
+
// lines onto a disconnected writer for as long as its read loop lasts.
|
|
1231
|
+
// The flag is checked between windows and between rows, so this takes
|
|
1232
|
+
// effect within one window rather than at the end of the whole import.
|
|
1233
|
+
if (activeMigration && activeMigration.state === "running") {
|
|
1234
|
+
activeMigration.cancel();
|
|
1235
|
+
}
|
|
1192
1236
|
if (writer) {
|
|
1193
1237
|
await writer.disconnect();
|
|
1194
1238
|
writer = null;
|
|
@@ -1552,17 +1596,11 @@ export default (app) => {
|
|
|
1552
1596
|
app.setPluginStatus(`Pulling QuestDB ${newTag}...`);
|
|
1553
1597
|
await containers.pullImage(`questdb/questdb:${newTag}`);
|
|
1554
1598
|
assertNotStopped();
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
reject(err);
|
|
1561
|
-
else
|
|
1562
|
-
resolve();
|
|
1563
|
-
});
|
|
1564
|
-
});
|
|
1565
|
-
}
|
|
1599
|
+
// NOTE: questdbVersion is persisted only AFTER a confirmed
|
|
1600
|
+
// recreate (below), not here — a wedged container defers the
|
|
1601
|
+
// recreate without throwing, and persisting newTag now would make
|
|
1602
|
+
// the plugin claim a version the still-running old container is not
|
|
1603
|
+
// actually on, surviving restarts.
|
|
1566
1604
|
const updateVolumeSource = await resolveQuestdbVolumeSource(containers);
|
|
1567
1605
|
app.setPluginStatus(`Starting QuestDB ${newTag}...`);
|
|
1568
1606
|
const updateConfig = {
|
|
@@ -1586,9 +1624,11 @@ export default (app) => {
|
|
|
1586
1624
|
// generation one more time.
|
|
1587
1625
|
assertNotStopped();
|
|
1588
1626
|
ulimitClamp = null;
|
|
1627
|
+
containerWedged = null;
|
|
1589
1628
|
containerEpoch++;
|
|
1590
1629
|
await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
|
|
1591
1630
|
onUlimitClamped,
|
|
1631
|
+
onContainerWedged,
|
|
1592
1632
|
});
|
|
1593
1633
|
assertNotStopped();
|
|
1594
1634
|
// The QuestDB version just changed, so the cached wal_tables()
|
|
@@ -1647,8 +1687,30 @@ export default (app) => {
|
|
|
1647
1687
|
assertNotStopped();
|
|
1648
1688
|
}
|
|
1649
1689
|
}
|
|
1690
|
+
// Persist the new version only now that the recreate is confirmed
|
|
1691
|
+
// (a wedge deferred it — the old container is still running).
|
|
1692
|
+
if (!containerWedged && currentConfig) {
|
|
1693
|
+
currentConfig.questdbVersion = newTag;
|
|
1694
|
+
await new Promise((resolve, reject) => {
|
|
1695
|
+
app.savePluginOptions({ ...currentConfig }, (err) => {
|
|
1696
|
+
if (err)
|
|
1697
|
+
reject(err);
|
|
1698
|
+
else
|
|
1699
|
+
resolve();
|
|
1700
|
+
});
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1650
1703
|
return { ilpHost, ilpPort };
|
|
1651
1704
|
});
|
|
1705
|
+
if (containerWedged) {
|
|
1706
|
+
// The recreate was deferred: the container is still on the OLD
|
|
1707
|
+
// version. Do not claim success — report the wedge and its remedy.
|
|
1708
|
+
res.status(409).json({
|
|
1709
|
+
error: "container wedged",
|
|
1710
|
+
message: containerWedged,
|
|
1711
|
+
});
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1652
1714
|
app.setPluginStatus(`Recording to QuestDB ${newTag} at ${ilp.ilpHost}:${ilp.ilpPort}`);
|
|
1653
1715
|
res.json({
|
|
1654
1716
|
status: "updated",
|
|
@@ -2067,64 +2129,270 @@ export default (app) => {
|
|
|
2067
2129
|
});
|
|
2068
2130
|
}
|
|
2069
2131
|
});
|
|
2132
|
+
// Strip the class down to the wire shape. `MigrationRun` carries a
|
|
2133
|
+
// `cancel()` method and a private flag that must not be serialised.
|
|
2134
|
+
const migrationRunView = (run) => ({
|
|
2135
|
+
id: run.id,
|
|
2136
|
+
state: run.state,
|
|
2137
|
+
url: run.url,
|
|
2138
|
+
bucket: run.bucket,
|
|
2139
|
+
startedAt: run.startedAt,
|
|
2140
|
+
finishedAt: run.finishedAt,
|
|
2141
|
+
progress: { ...run.progress },
|
|
2142
|
+
error: run.error,
|
|
2143
|
+
});
|
|
2144
|
+
// Auth travels in the request BODY rather than plugin config: the token
|
|
2145
|
+
// is only needed for the duration of an import and storing it in the
|
|
2146
|
+
// plugin's settings file would persist a credential the user never
|
|
2147
|
+
// asked us to keep. Never the query string — Signal K logs full request
|
|
2148
|
+
// URLs (morgan "combined"), so a token there reaches the access log.
|
|
2149
|
+
const readAuth = (src) => {
|
|
2150
|
+
const token = typeof src.token === "string" ? src.token : undefined;
|
|
2151
|
+
const username = typeof src.username === "string" ? src.username : undefined;
|
|
2152
|
+
const password = typeof src.password === "string" ? src.password : undefined;
|
|
2153
|
+
const org = typeof src.org === "string" ? src.org : undefined;
|
|
2154
|
+
if (!token && !username && !org)
|
|
2155
|
+
return undefined;
|
|
2156
|
+
return { token, username, password, org };
|
|
2157
|
+
};
|
|
2070
2158
|
router.get("/api/migration/detect", async (req, res) => {
|
|
2071
|
-
|
|
2159
|
+
// `?url=a&url=b` arrives as an ARRAY, not a string — a cast here
|
|
2160
|
+
// would hand a non-string to validateInfluxUrl and throw inside the
|
|
2161
|
+
// handler rather than answering 400.
|
|
2162
|
+
const requestedUrl = req.query.url;
|
|
2163
|
+
if (requestedUrl !== undefined && typeof requestedUrl !== "string") {
|
|
2164
|
+
res.status(400).json({
|
|
2165
|
+
error: "url must be a single value",
|
|
2166
|
+
sources: [],
|
|
2167
|
+
});
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const baseUrl = validateInfluxUrl(requestedUrl || "http://localhost:8086");
|
|
2171
|
+
if (!baseUrl) {
|
|
2172
|
+
res.status(400).json({
|
|
2173
|
+
error: "Only localhost and private network http(s) URLs allowed",
|
|
2174
|
+
sources: [],
|
|
2175
|
+
});
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2072
2178
|
try {
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2075
|
-
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
2076
|
-
const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
|
|
2077
|
-
if (!isLocal && !isPrivate) {
|
|
2078
|
-
res.status(400).json({
|
|
2079
|
-
error: "Only localhost and private network URLs allowed",
|
|
2080
|
-
});
|
|
2081
|
-
return;
|
|
2082
|
-
}
|
|
2179
|
+
const sources = await detectInflux(baseUrl);
|
|
2180
|
+
res.json({ sources });
|
|
2083
2181
|
}
|
|
2084
|
-
catch {
|
|
2085
|
-
res.status(
|
|
2182
|
+
catch (err) {
|
|
2183
|
+
res.status(500).json({
|
|
2184
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
2185
|
+
sources: [],
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
// POST, not GET, purely because of the credentials. Signal K mounts
|
|
2190
|
+
// morgan("combined"), which writes the full request URL — query string
|
|
2191
|
+
// included — to the access log, and browsers keep it in history. An
|
|
2192
|
+
// InfluxDB API token in a query parameter therefore ends up in both.
|
|
2193
|
+
// Discovery is a read, but the token has to travel in the body.
|
|
2194
|
+
router.post("/api/migration/buckets", async (req, res) => {
|
|
2195
|
+
const body = (req.body ?? {});
|
|
2196
|
+
const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
|
|
2197
|
+
if (!baseUrl) {
|
|
2198
|
+
res.status(400).json({
|
|
2199
|
+
error: "Invalid URL",
|
|
2200
|
+
buckets: [],
|
|
2201
|
+
});
|
|
2086
2202
|
return;
|
|
2087
2203
|
}
|
|
2088
|
-
const sources = [];
|
|
2089
|
-
// Detect InfluxDB 1.x
|
|
2090
2204
|
try {
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2205
|
+
const buckets = await listBuckets({
|
|
2206
|
+
url: baseUrl,
|
|
2207
|
+
type: typeof body.type === "string" ? body.type : "influxdb2",
|
|
2208
|
+
auth: readAuth(body),
|
|
2094
2209
|
});
|
|
2095
|
-
|
|
2096
|
-
sources.push({
|
|
2097
|
-
type: "influxdb1",
|
|
2098
|
-
url: baseUrl,
|
|
2099
|
-
status: "found",
|
|
2100
|
-
version: r.headers.get("X-Influxdb-Version") || "unknown",
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2210
|
+
res.json({ buckets });
|
|
2103
2211
|
}
|
|
2104
|
-
catch {
|
|
2105
|
-
|
|
2212
|
+
catch (err) {
|
|
2213
|
+
res.status(502).json({
|
|
2214
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
2215
|
+
buckets: [],
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
// POST for the same reason as /buckets: the token must not reach the
|
|
2220
|
+
// access log or the browser's history via the query string.
|
|
2221
|
+
router.post("/api/migration/measurements", async (req, res) => {
|
|
2222
|
+
const body = (req.body ?? {});
|
|
2223
|
+
const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
|
|
2224
|
+
const bucket = typeof body.bucket === "string" ? body.bucket : "";
|
|
2225
|
+
if (!baseUrl || !bucket) {
|
|
2226
|
+
res.status(400).json({
|
|
2227
|
+
error: "Missing or invalid url/bucket",
|
|
2228
|
+
measurements: [],
|
|
2229
|
+
});
|
|
2230
|
+
return;
|
|
2106
2231
|
}
|
|
2107
|
-
// Detect InfluxDB 2.x
|
|
2108
2232
|
try {
|
|
2109
|
-
const
|
|
2110
|
-
|
|
2233
|
+
const measurements = await listMeasurements({
|
|
2234
|
+
url: baseUrl,
|
|
2235
|
+
type: typeof body.type === "string" ? body.type : "influxdb2",
|
|
2236
|
+
bucket,
|
|
2237
|
+
auth: readAuth(body),
|
|
2111
2238
|
});
|
|
2112
|
-
|
|
2113
|
-
const data = (await r.json());
|
|
2114
|
-
if (data.status === "pass") {
|
|
2115
|
-
sources.push({
|
|
2116
|
-
type: "influxdb2",
|
|
2117
|
-
url: baseUrl,
|
|
2118
|
-
status: "found",
|
|
2119
|
-
version: data.version || "unknown",
|
|
2120
|
-
});
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2239
|
+
res.json({ measurements });
|
|
2123
2240
|
}
|
|
2124
|
-
catch {
|
|
2125
|
-
|
|
2241
|
+
catch (err) {
|
|
2242
|
+
res.status(502).json({
|
|
2243
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
2244
|
+
measurements: [],
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
router.post("/api/migration/start", async (req, res) => {
|
|
2249
|
+
// One import at a time. Two concurrent runs would interleave on the
|
|
2250
|
+
// single ILP writer and race each other for its buffer, and there is
|
|
2251
|
+
// no sane way to show two progress bars against one writer.
|
|
2252
|
+
if (activeMigration && activeMigration.state === "running") {
|
|
2253
|
+
res.status(409).json({
|
|
2254
|
+
error: "A migration is already running",
|
|
2255
|
+
run: migrationRunView(activeMigration),
|
|
2256
|
+
});
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (!writer) {
|
|
2260
|
+
res.status(503).json({
|
|
2261
|
+
error: "QuestDB writer not connected",
|
|
2262
|
+
});
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
const body = (req.body ?? {});
|
|
2266
|
+
const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
|
|
2267
|
+
const bucket = typeof body.bucket === "string" ? body.bucket : "";
|
|
2268
|
+
if (!baseUrl || !bucket) {
|
|
2269
|
+
res.status(400).json({
|
|
2270
|
+
error: "Missing or invalid url/bucket",
|
|
2271
|
+
});
|
|
2272
|
+
return;
|
|
2126
2273
|
}
|
|
2127
|
-
|
|
2274
|
+
const from = typeof body.from === "string" ? body.from : "";
|
|
2275
|
+
const to = typeof body.to === "string" ? body.to : "";
|
|
2276
|
+
if (!from ||
|
|
2277
|
+
!to ||
|
|
2278
|
+
Number.isNaN(Date.parse(from)) ||
|
|
2279
|
+
Number.isNaN(Date.parse(to))) {
|
|
2280
|
+
res.status(400).json({
|
|
2281
|
+
error: "Missing or invalid from/to",
|
|
2282
|
+
});
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
// An inverted range is rejected here rather than left to fail inside
|
|
2286
|
+
// the run: the caller gets a 400 it can act on, instead of a 200
|
|
2287
|
+
// followed by a run that reports "failed" a moment later.
|
|
2288
|
+
if (Date.parse(to) <= Date.parse(from)) {
|
|
2289
|
+
res.status(400).json({
|
|
2290
|
+
error: "`to` must be after `from`",
|
|
2291
|
+
});
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
// "self" and "vessels.self" both mean this vessel; anything else must
|
|
2295
|
+
// be a fully-qualified Signal K context (vessels.urn:mrn:..., or
|
|
2296
|
+
// atons./aircraft./sar. for other targets).
|
|
2297
|
+
//
|
|
2298
|
+
// The own vessel is stored as the LITERAL string "self", not as
|
|
2299
|
+
// `vessels.<uuid>` — that is what the live recorder writes (see the
|
|
2300
|
+
// `isSelf ? "self" : context` in the delta handler) and what both
|
|
2301
|
+
// history providers normalise incoming contexts to. Importing under
|
|
2302
|
+
// the expanded uuid would file the history in a context no query ever
|
|
2303
|
+
// looks at: the rows would be there and the API would return nothing.
|
|
2304
|
+
const rawContext = typeof body.context === "string" ? body.context.trim() : "";
|
|
2305
|
+
let importContext;
|
|
2306
|
+
if (!rawContext ||
|
|
2307
|
+
rawContext === "self" ||
|
|
2308
|
+
rawContext === "vessels.self" ||
|
|
2309
|
+
rawContext === `vessels.${app.selfId}`) {
|
|
2310
|
+
importContext = "self";
|
|
2311
|
+
}
|
|
2312
|
+
else if (/^(vessels|atons|aircraft|sar)\.[A-Za-z0-9:._-]+$/.test(rawContext)) {
|
|
2313
|
+
importContext = rawContext;
|
|
2314
|
+
}
|
|
2315
|
+
else {
|
|
2316
|
+
res.status(400).json({
|
|
2317
|
+
error: "Invalid context: expected `self` or a Signal K context such as `vessels.urn:mrn:imo:mmsi:123456789`",
|
|
2318
|
+
});
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
// Kept deliberately narrow: a label is a short identifier, not free
|
|
2322
|
+
// text. Anything else falls back to the default.
|
|
2323
|
+
const rawLabel = typeof body.sourceLabel === "string" ? body.sourceLabel.trim() : "";
|
|
2324
|
+
const migrationSourceLabel = rawLabel && /^[A-Za-z0-9._:-]{1,64}$/.test(rawLabel)
|
|
2325
|
+
? rawLabel
|
|
2326
|
+
: "influxdb-import";
|
|
2327
|
+
const request = {
|
|
2328
|
+
url: baseUrl,
|
|
2329
|
+
type: typeof body.type === "string" ? body.type : "influxdb2",
|
|
2330
|
+
bucket,
|
|
2331
|
+
auth: readAuth(body),
|
|
2332
|
+
from,
|
|
2333
|
+
to,
|
|
2334
|
+
// Default to the server's own vessel: that is what a boat migrating
|
|
2335
|
+
// its own history means by "my data". A supplied context is checked
|
|
2336
|
+
// against the Signal K shape rather than passed through — it becomes
|
|
2337
|
+
// a SYMBOL value on every imported row and is what the history API
|
|
2338
|
+
// queries by, so a malformed one writes history nothing can read.
|
|
2339
|
+
context: importContext,
|
|
2340
|
+
measurements: Array.isArray(body.measurements)
|
|
2341
|
+
? body.measurements.filter((m) => typeof m === "string")
|
|
2342
|
+
: undefined,
|
|
2343
|
+
// The label is written as the `source` tag on every imported row,
|
|
2344
|
+
// which is both a dedup key component and how an operator tells
|
|
2345
|
+
// imported history from live recording. A blank or whitespace-only
|
|
2346
|
+
// value would leave the tag off entirely (making imported rows
|
|
2347
|
+
// indistinguishable from recorded ones, and changing the dedup
|
|
2348
|
+
// key so a re-run duplicates instead of upserting), so it falls
|
|
2349
|
+
// back to the default rather than being trusted as given.
|
|
2350
|
+
sourceLabel: migrationSourceLabel,
|
|
2351
|
+
};
|
|
2352
|
+
// The id only has to be unique within this process, where the counter
|
|
2353
|
+
// already guarantees it; the timestamp just makes logs readable.
|
|
2354
|
+
const run = new MigrationRun(`mig-${Date.now()}-${++migrationCounter}`, baseUrl, bucket);
|
|
2355
|
+
activeMigration = run;
|
|
2356
|
+
const startedWriter = writer;
|
|
2357
|
+
// Deliberately not awaited: an import runs for minutes to hours, so
|
|
2358
|
+
// the HTTP call returns immediately and the panel polls /status.
|
|
2359
|
+
void runMigration(request, startedWriter, run, {
|
|
2360
|
+
debug: (msg) => app.debug(msg),
|
|
2361
|
+
})
|
|
2362
|
+
.catch((err) => {
|
|
2363
|
+
// runMigration traps its own failures, but a few statements run
|
|
2364
|
+
// BEFORE its try block (auth header construction). A rejection
|
|
2365
|
+
// from there would be an unhandled promise rejection on a
|
|
2366
|
+
// deliberately un-awaited call — fatal to the Signal K process
|
|
2367
|
+
// under Node's default policy — and would leave the run stuck
|
|
2368
|
+
// reading "running" forever.
|
|
2369
|
+
run.state = "failed";
|
|
2370
|
+
run.error = err instanceof Error ? err.message : String(err);
|
|
2371
|
+
run.finishedAt = new Date().toISOString();
|
|
2372
|
+
})
|
|
2373
|
+
.finally(() => {
|
|
2374
|
+
app.debug(`migration ${run.id} ${run.state}: ${run.progress.written} written, ${run.progress.skipped} skipped`);
|
|
2375
|
+
});
|
|
2376
|
+
res.json({
|
|
2377
|
+
run: migrationRunView(run),
|
|
2378
|
+
});
|
|
2379
|
+
});
|
|
2380
|
+
router.get("/api/migration/status", (_req, res) => {
|
|
2381
|
+
res.json({
|
|
2382
|
+
run: activeMigration ? migrationRunView(activeMigration) : undefined,
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
router.post("/api/migration/cancel", (_req, res) => {
|
|
2386
|
+
if (!activeMigration || activeMigration.state !== "running") {
|
|
2387
|
+
res.status(409).json({
|
|
2388
|
+
error: "No migration running",
|
|
2389
|
+
});
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
activeMigration.cancel();
|
|
2393
|
+
res.json({
|
|
2394
|
+
run: migrationRunView(activeMigration),
|
|
2395
|
+
});
|
|
2128
2396
|
});
|
|
2129
2397
|
router.get("/api/export", async (req, res) => {
|
|
2130
2398
|
try {
|