signalk-underkeel 0.5.1 → 0.6.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/CHANGELOG.md +37 -0
- package/README.md +18 -5
- package/lib/api/manifest.js +1 -0
- package/lib/box-status.js +19 -0
- package/lib/cell-warm.js +110 -0
- package/lib/index-loader.js +5 -2
- package/lib/index.js +60 -5
- package/lib/ingest/cell-cache.js +54 -12
- package/lib/ingest/enc-read-process.js +5 -3
- package/lib/monitor.js +4 -4
- package/lib/shared-enc.js +27 -8
- package/lib/spatial/build-survey.js +8 -3
- package/lib/status-report.js +5 -3
- package/lib/wfs-service.js +38 -28
- package/lib/wfs-wiring.js +41 -8
- package/lib/wfs-worker.js +30 -0
- package/package.json +1 -1
- package/public/panel.html +15 -7
- package/public/panel.js +15 -9
- package/public/setup.html +6 -15
- package/public/setup.js +3 -7
- package/public/theme.css +59 -0
- package/public/theme.js +75 -0
- package/public/ukc-shared.js +17 -9
- package/public/widget.html +9 -3
- package/public/widget.js +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to signalk-underkeel are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.6.1] - 2026-11-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The details panel reads in dark mode (#67): it had fixed light-theme colours and no
|
|
10
|
+
background of its own, so on Freeboard's dark background most of its text was dark grey on
|
|
11
|
+
dark. The status chip and widget also put white on amber (Alert, 2.4:1) and on green
|
|
12
|
+
(Normal, 3.7:1), below the 4.5:1 that small text needs.
|
|
13
|
+
- The SignalK plugin CI workflow is pinned to a commit again. Without the `@ref`, GitHub could
|
|
14
|
+
not read the workflow file, so no plugin CI ran for 0.6.0 and the plugin registry took 10
|
|
15
|
+
points from its score.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The widget, the details panel and the setup steps share one set of colours in three
|
|
20
|
+
schemes: day, dusk (the device's dark mode) and night (Freeboard's night mode: black, and
|
|
21
|
+
nothing brighter than a dim grey). Every text colour reads at WCAG AA in each scheme.
|
|
22
|
+
|
|
23
|
+
## [0.6.0] - 2026-11-10
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Charts Provider sharing its store after this plugin started, as it does at every server
|
|
28
|
+
start once its charts are loaded, no longer restarts the plugin when the store holds the
|
|
29
|
+
cells read at start; only the note that it was not running goes.
|
|
30
|
+
- The first chart box is built as soon as the first position arrives, not at the next 10 s
|
|
31
|
+
check. Until it is in use the reason says the box is being built and how far it has come,
|
|
32
|
+
for example `building the chart box around the vessel: reading cells 3/7`, and the cells
|
|
33
|
+
phase counts each cell as it is read instead of jumping from 0 to all.
|
|
34
|
+
- A charts provider still starting, or one that does not download an area, goes to the debug
|
|
35
|
+
log instead of the error log; one too old to be asked, or whose answer failed, stays an
|
|
36
|
+
error.
|
|
37
|
+
- Once a chart box is in use, the cells the next box can reach are read into the cell cache by
|
|
38
|
+
a child process at the lowest CPU priority, so moving the box over them reads none during the
|
|
39
|
+
rebuild: after a 6 km move the Waddenzee box rebuilt in 8.6 s against 34 s for its first
|
|
40
|
+
build (x86).
|
|
41
|
+
|
|
5
42
|
## [0.5.1] - 2026-11-10
|
|
6
43
|
|
|
7
44
|
### Changed
|
package/README.md
CHANGED
|
@@ -144,8 +144,10 @@ phase, start and the last build's duration. At speed the box grows past its radi
|
|
|
144
144
|
**Largest index box at speed** (0 sizes it from the RAM), so the vessel cannot reach the edge
|
|
145
145
|
before the next box is built (docs/HOW-IT-WORKS.md §12). A radius under 2 km is raised to 2.
|
|
146
146
|
Until the first position arrives the engine is `off`
|
|
147
|
-
with the reason `waiting for the vessel position to build the chart box
|
|
148
|
-
|
|
147
|
+
with the reason `waiting for the vessel position to build the chart box`; the box is built
|
|
148
|
+
as soon as one does, and meanwhile the reason says how far it has come, for example
|
|
149
|
+
`building the chart box around the vessel: reading cells 3/7`. Set the box to 0 to index every
|
|
150
|
+
cell.
|
|
149
151
|
|
|
150
152
|
At the first start the plugin builds a chart index from the cells in a child process and saves
|
|
151
153
|
it under the plugin data directory, `~/.signalk/plugin-config-data/signalk-underkeel/index`
|
|
@@ -157,8 +159,11 @@ changed; `GET /status` names the cells that caused the rebuild. **Rebuild index
|
|
|
157
159
|
forces a rebuild on every start. A cell folder may be a symbolic link. What the build read
|
|
158
160
|
from each cell is kept, compressed, in `cell-cache` in the plugin data directory (at most 512 MB), so a
|
|
159
161
|
chart box refresh reads again only the cells whose files changed: the 10 km Waddenzee box
|
|
160
|
-
rebuilds in 12–15 s instead of 30–34 s on x86.
|
|
161
|
-
|
|
162
|
+
rebuilds in 12–15 s instead of 30–34 s on x86. Once a box is in use, a child process at the
|
|
163
|
+
lowest CPU priority reads into it the cells the next box can reach, so a box moved over cells it
|
|
164
|
+
had not met reads none during the rebuild: after a 6 km move the Waddenzee box was rebuilt in
|
|
165
|
+
8.6 s against 34 s for its first build (x86). A rebuild ends that process first. Deleting the
|
|
166
|
+
folder is safe; the cells are then read again.
|
|
162
167
|
|
|
163
168
|
Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
|
|
164
169
|
longer read: convert the original `.000` cells in the charts provider instead. Such a folder
|
|
@@ -270,6 +275,11 @@ Extensions API shows it without any change to Freeboard:
|
|
|
270
275
|
| Widget grid | Under-keel status: state colour, minimum clearance, time to shoal and the reason |
|
|
271
276
|
| Toolbar button (boat icon) | details panel: state, water level and source, a leg table per route, and **Open setup** for boat, water level and warning style (Settings) |
|
|
272
277
|
|
|
278
|
+
The widget, the panel and the setup steps follow Freeboard's look: light by day, dark when the
|
|
279
|
+
device is in dark mode, and a dim night scheme, black with nothing brighter than a dim grey,
|
|
280
|
+
while Freeboard's night mode is on, so a glance at the panel keeps the eyes adapted to the
|
|
281
|
+
dark. Every text colour reads at WCAG AA in each scheme.
|
|
282
|
+
|
|
273
283
|
Routes shown on the chart are checked 1 s after each edit and every minute; the panel shows
|
|
274
284
|
each result's age and marks it "out of date" after 3 minutes, when the chart draws its legs
|
|
275
285
|
`unverified`. Stale status is shown as Unknown,
|
|
@@ -497,7 +507,10 @@ panel with its toolbar button, a hidden background page and the experimental
|
|
|
497
507
|
- `runtime.js`, the background page, keeps the envelope, route results and tiles current;
|
|
498
508
|
- `widget.js` and `panel.js` render the status and the route legs;
|
|
499
509
|
- `ukc-shared.js` holds their pure logic (freshness, badges, labels), the part the Jest tests
|
|
500
|
-
cover
|
|
510
|
+
cover;
|
|
511
|
+
- `theme.css` holds every colour of the pages in the day, dusk and night schemes, and
|
|
512
|
+
`theme.js` picks the scheme from Freeboard's night mode (`nightMode`) or the device's dark
|
|
513
|
+
mode; a test holds each text colour to WCAG AA contrast in every scheme.
|
|
501
514
|
|
|
502
515
|
The pages talk to the host through the vendored `signalk-plotterext-bus` client, copied into
|
|
503
516
|
`public/vendor/` by the build. The [Plotter Extensions services RFC](docs/rfcs/plotter-extensions-services.md)
|
package/lib/api/manifest.js
CHANGED
package/lib/box-status.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPending = void 0;
|
|
3
4
|
exports.rebuildStatus = rebuildStatus;
|
|
5
|
+
exports.noIndexReason = noIndexReason;
|
|
6
|
+
exports.phaseText = phaseText;
|
|
4
7
|
exports.boxSummary = boxSummary;
|
|
5
8
|
const box_sizing_1 = require("./box-sizing");
|
|
6
9
|
const chart_box_1 = require("./engine/chart-box");
|
|
10
|
+
const index_loader_1 = require("./index-loader");
|
|
7
11
|
function rebuildStatus(r, now) {
|
|
8
12
|
return {
|
|
9
13
|
startedAt: new Date(r.startedAt).toISOString(),
|
|
@@ -13,6 +17,21 @@ function rebuildStatus(r, now) {
|
|
|
13
17
|
...(r.estimateMs === undefined ? {} : { estimateMs: r.estimateMs })
|
|
14
18
|
};
|
|
15
19
|
}
|
|
20
|
+
/** Whether `reason` is one of PENDING_REASONS, maybe with a note (`; …`) or detail (`: …`). */
|
|
21
|
+
const isPending = (reason) => [...index_loader_1.PENDING_REASONS].some((p) => reason.startsWith(p) && /^(?:$|[;:] )/.test(reason.slice(p.length)));
|
|
22
|
+
exports.isPending = isPending;
|
|
23
|
+
/**
|
|
24
|
+
* Why there is no index: `reason`, or what the first chart box build is doing while it runs,
|
|
25
|
+
* as the vessel position it waited for has come.
|
|
26
|
+
*/
|
|
27
|
+
function noIndexReason(reason, box) {
|
|
28
|
+
const why = reason ?? "no chart index loaded";
|
|
29
|
+
if (box?.rebuilding && why.startsWith(index_loader_1.BOX_WAITING_REASON)) {
|
|
30
|
+
return box.phase ? `${index_loader_1.BOX_BUILDING_REASON}: ${box.phase}` : index_loader_1.BOX_BUILDING_REASON;
|
|
31
|
+
}
|
|
32
|
+
return why;
|
|
33
|
+
}
|
|
34
|
+
/** What a box build in progress is doing, with its count where it gave one. */
|
|
16
35
|
function phaseText(r) {
|
|
17
36
|
const of = r.done !== undefined && r.total !== undefined ? ` ${r.done}/${r.total}` : "";
|
|
18
37
|
switch (r.phase) {
|
package/lib/cell-warm.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.warmCells = warmCells;
|
|
7
|
+
exports.startWarming = startWarming;
|
|
8
|
+
exports.boxWarmer = boxWarmer;
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const worker_threads_1 = require("worker_threads");
|
|
12
|
+
const child_process_1 = require("./foundation/child-process");
|
|
13
|
+
const index_loader_1 = require("./index-loader");
|
|
14
|
+
const cell_cache_1 = require("./ingest/cell-cache");
|
|
15
|
+
const cell_source_1 = require("./ingest/cell-source");
|
|
16
|
+
const enc_read_process_1 = require("./ingest/enc-read-process");
|
|
17
|
+
const build_box_1 = require("./spatial/build-box");
|
|
18
|
+
/** Reads the cells of `job.around` the cache lacks into it; resolves with how many were read. */
|
|
19
|
+
async function warmCells(job, reader = enc_read_process_1.readInProcess) {
|
|
20
|
+
const cells = (0, index_loader_1.cellSources)(job.cellsDir).filter(cell_source_1.isEncFile);
|
|
21
|
+
const cache = cell_cache_1.LayerCache.open(job.cacheDir, cells);
|
|
22
|
+
if (!cache) {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
cache.hold((0, build_box_1.boxCells)(cells, job.hold, job.cacheDir));
|
|
26
|
+
const want = (0, build_box_1.boxCells)(cells, job.around, job.cacheDir).filter((f) => !cache.has(f));
|
|
27
|
+
let read = 0;
|
|
28
|
+
for (const file of want) {
|
|
29
|
+
const layers = (await reader([file])).get(file);
|
|
30
|
+
if (layers) {
|
|
31
|
+
cache.put(file, layers);
|
|
32
|
+
read++;
|
|
33
|
+
}
|
|
34
|
+
// One cell's layers held at a time.
|
|
35
|
+
await cache.flush();
|
|
36
|
+
}
|
|
37
|
+
return read;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Runs `job` in a child process from the compiled `script`, its heap capped at `heapMb`;
|
|
41
|
+
* `done` gets its reply, or why it ended without one, unless it was stopped.
|
|
42
|
+
*/
|
|
43
|
+
function startWarming(job, heapMb, done, script = path_1.default.join(__dirname, "cell-warm.js")) {
|
|
44
|
+
const { child, stderr } = (0, child_process_1.forkLimited)(script, heapMb, true);
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let reply;
|
|
47
|
+
const closed = new Promise((resolve) => {
|
|
48
|
+
child.once("close", (code) => {
|
|
49
|
+
if (!stopped) {
|
|
50
|
+
done(reply ?? { error: `cell cache warming exited with ${code ?? "a signal"}: ${stderr()}` });
|
|
51
|
+
}
|
|
52
|
+
resolve();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
child.once("error", () => undefined);
|
|
56
|
+
child.on("message", (m) => (reply = m));
|
|
57
|
+
child.send(job, () => undefined);
|
|
58
|
+
return {
|
|
59
|
+
stop: () => {
|
|
60
|
+
stopped = true;
|
|
61
|
+
(0, child_process_1.killGroup)(child);
|
|
62
|
+
return closed;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** One run at a time through `start`; a run that cannot start is reported to `error`. */
|
|
67
|
+
function boxWarmer(start, error) {
|
|
68
|
+
let running;
|
|
69
|
+
let stopped = false;
|
|
70
|
+
const end = () => {
|
|
71
|
+
const r = running;
|
|
72
|
+
running = undefined;
|
|
73
|
+
return r ? r.stop() : Promise.resolve();
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
boxInUse: (bbox, reachKm) => {
|
|
77
|
+
if (stopped) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
void end();
|
|
81
|
+
try {
|
|
82
|
+
running = start((0, build_box_1.growBbox)(bbox, reachKm), bbox);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
error(`filling the cell cache failed: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
beforeBuild: end,
|
|
89
|
+
stop: () => {
|
|
90
|
+
stopped = true;
|
|
91
|
+
void end();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (worker_threads_1.isMainThread && require.main === module && process.send) {
|
|
96
|
+
(0, child_process_1.exitWithParent)();
|
|
97
|
+
try {
|
|
98
|
+
// Behind the server and the look-ahead, whatever the cores.
|
|
99
|
+
os_1.default.setPriority(19);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not permitted: it runs at the server's priority.
|
|
103
|
+
}
|
|
104
|
+
process.once("message", (job) => {
|
|
105
|
+
warmCells(job)
|
|
106
|
+
.then((read) => ({ read }), (err) => ({ error: err.message }))
|
|
107
|
+
.then(child_process_1.sendToParent)
|
|
108
|
+
.finally(() => process.exit(0));
|
|
109
|
+
});
|
|
110
|
+
}
|
package/lib/index-loader.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
|
|
6
|
+
exports.failedLoad = exports.cellCacheDir = exports.SYNC_LOAD_BYTES = exports.PENDING_REASONS = exports.BOX_BUILDING_REASON = exports.BOX_WAITING_REASON = exports.WFS_WAITING_REASON = exports.LOADING_REASON = exports.BUILDING_REASON = void 0;
|
|
7
7
|
exports.cellSources = cellSources;
|
|
8
8
|
exports.cellsMissing = cellsMissing;
|
|
9
9
|
exports.savedCatalogue = savedCatalogue;
|
|
@@ -27,12 +27,15 @@ exports.BUILDING_REASON = "building chart index";
|
|
|
27
27
|
exports.LOADING_REASON = "loading chart index";
|
|
28
28
|
exports.WFS_WAITING_REASON = "waiting for the first Traficom WFS download";
|
|
29
29
|
exports.BOX_WAITING_REASON = "waiting for the vessel position to build the chart box";
|
|
30
|
+
/** Why there is no index while the first chart box around the vessel is built. */
|
|
31
|
+
exports.BOX_BUILDING_REASON = "building the chart box around the vessel";
|
|
30
32
|
/** Reasons that mean the index is on its way, not that loading it failed. */
|
|
31
33
|
exports.PENDING_REASONS = new Set([
|
|
32
34
|
exports.BUILDING_REASON,
|
|
33
35
|
exports.LOADING_REASON,
|
|
34
36
|
exports.WFS_WAITING_REASON,
|
|
35
|
-
exports.BOX_WAITING_REASON
|
|
37
|
+
exports.BOX_WAITING_REASON,
|
|
38
|
+
exports.BOX_BUILDING_REASON
|
|
36
39
|
]);
|
|
37
40
|
/**
|
|
38
41
|
* A saved pyramid.bin larger than this is read and inflated off the event
|
package/lib/index.js
CHANGED
|
@@ -66,7 +66,7 @@ function stopPlugin(app, clock, state) {
|
|
|
66
66
|
* Restarts the plugin once the shared ENC cells it reads changed: a start compares them with the
|
|
67
67
|
* saved index and rebuilds it when they did. A server without a restart callback logs instead.
|
|
68
68
|
*/
|
|
69
|
-
function watchShared(app, clock, collection, restart) {
|
|
69
|
+
function watchShared(app, clock, collection, restart, sameCells) {
|
|
70
70
|
return (0, shared_enc_1.watchSharedCells)(collection, clock, () => {
|
|
71
71
|
if (restart) {
|
|
72
72
|
restart();
|
|
@@ -74,7 +74,64 @@ function watchShared(app, clock, collection, restart) {
|
|
|
74
74
|
else {
|
|
75
75
|
app.error("the shared ENC cells changed; restart the plugin to index them");
|
|
76
76
|
}
|
|
77
|
-
});
|
|
77
|
+
}, sameCells ? { sameCells } : {});
|
|
78
|
+
}
|
|
79
|
+
/** The stamps of the S-57 cells under `dir`, as one string; undefined when it cannot be read. */
|
|
80
|
+
function cellsStamp(dir) {
|
|
81
|
+
try {
|
|
82
|
+
return (0, index_loader_1.sourceKeys)((0, index_loader_1.cellSources)(dir)).join("\n");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Watches the charts provider's cells in use (`watchShared`). A provider publishing later the
|
|
90
|
+
* cells read at start restarts nothing: the reason shown loses its note that it had not started.
|
|
91
|
+
*/
|
|
92
|
+
function watchAtStart(app, clock, state, plan, restart) {
|
|
93
|
+
const { config, load } = state;
|
|
94
|
+
const collection = config?.chartsProviderEnc;
|
|
95
|
+
if (!config || collection === undefined) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const same = sameCellsAsStart(config.cellsDir, collection);
|
|
99
|
+
const renote = () => {
|
|
100
|
+
if ("load" in plan && state.load === load && load?.reason !== undefined) {
|
|
101
|
+
const fresh = (0, shared_enc_1.noteSharedCells)(plan.load, config);
|
|
102
|
+
state.load = fresh;
|
|
103
|
+
if (!fresh.index && fresh.reason !== undefined) {
|
|
104
|
+
state.monitor?.noIndex(fresh.reason);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const quiet = same &&
|
|
109
|
+
((root) => {
|
|
110
|
+
const unchanged = same(root);
|
|
111
|
+
if (unchanged) {
|
|
112
|
+
renote();
|
|
113
|
+
}
|
|
114
|
+
return unchanged;
|
|
115
|
+
});
|
|
116
|
+
return watchShared(app, clock, collection, restart, quiet);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a charts provider that publishes its store later than this plugin starts, as it does
|
|
120
|
+
* once its charts are loaded, publishes the cells read at start: the same directory, the same
|
|
121
|
+
* cells. Undefined when the provider was there at start, or nothing was read.
|
|
122
|
+
*/
|
|
123
|
+
function sameCellsAsStart(cellsDir, collection) {
|
|
124
|
+
if ((0, shared_enc_1.sharedEncApi)() !== undefined || cellsDir === undefined) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const stamp = cellsStamp(cellsDir);
|
|
128
|
+
if (stamp === undefined) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return (root) => {
|
|
132
|
+
const dir = collection === shared_enc_1.ALL_COLLECTIONS ? root : path_1.default.join(root, collection);
|
|
133
|
+
return path_1.default.resolve(dir) === path_1.default.resolve(cellsDir) && cellsStamp(dir) === stamp;
|
|
134
|
+
};
|
|
78
135
|
}
|
|
79
136
|
/**
|
|
80
137
|
* The admin form for this server: the essentials with expert settings
|
|
@@ -145,9 +202,7 @@ module.exports = function underkeel(app, clock = monitor_1.SYSTEM_CLOCK, fetch =
|
|
|
145
202
|
state.zoneErrors = errors;
|
|
146
203
|
errors.forEach((e) => app.error(e));
|
|
147
204
|
});
|
|
148
|
-
shared = config
|
|
149
|
-
? watchShared(app, clock, config.chartsProviderEnc, restart && (() => restart(migration.config)))
|
|
150
|
-
: undefined;
|
|
205
|
+
shared = watchAtStart(app, clock, state, plan, restart && (() => restart(migration.config)));
|
|
151
206
|
(0, assets_1.mountAssets)(app);
|
|
152
207
|
app.registerResourceProvider((0, manifest_1.manifestProvider)(VERSION));
|
|
153
208
|
},
|
package/lib/ingest/cell-cache.js
CHANGED
|
@@ -68,8 +68,8 @@ function writeQuietly(file, data) {
|
|
|
68
68
|
fs_1.default.rmSync(tmp, { force: true });
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
/** The
|
|
72
|
-
function
|
|
71
|
+
/** The entry's header and where its body lies; "corrupt" for a damaged entry. */
|
|
72
|
+
function entryHead(bytes) {
|
|
73
73
|
const end = bytes.length - SUM_BYTES;
|
|
74
74
|
const headAt = MAGIC.length + 4;
|
|
75
75
|
if (end < headAt || !bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
@@ -79,16 +79,27 @@ function decodeEntry(bytes, key) {
|
|
|
79
79
|
return "corrupt";
|
|
80
80
|
}
|
|
81
81
|
const bodyAt = headAt + bytes.readUInt32LE(MAGIC.length);
|
|
82
|
-
|
|
82
|
+
try {
|
|
83
|
+
return bodyAt > end
|
|
84
|
+
? "corrupt"
|
|
85
|
+
: { head: JSON.parse(bytes.toString("utf8", headAt, bodyAt)), bodyAt, end };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return "corrupt";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The layers in `bytes` filed under `key`; "stale" for another key, "corrupt" for a damaged entry. */
|
|
92
|
+
function decodeEntry(bytes, key) {
|
|
93
|
+
const entry = entryHead(bytes);
|
|
94
|
+
if (entry === "corrupt") {
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
const { head, bodyAt, end } = entry;
|
|
98
|
+
if (head.key !== key) {
|
|
99
|
+
return "stale";
|
|
100
|
+
}
|
|
83
101
|
let raw;
|
|
84
102
|
try {
|
|
85
|
-
if (bodyAt > end) {
|
|
86
|
-
return "corrupt";
|
|
87
|
-
}
|
|
88
|
-
head = JSON.parse(bytes.toString("utf8", headAt, bodyAt));
|
|
89
|
-
if (head.key !== key) {
|
|
90
|
-
return "stale";
|
|
91
|
-
}
|
|
92
103
|
raw = zlib_1.default.brotliDecompressSync(bytes.subarray(bodyAt, end));
|
|
93
104
|
}
|
|
94
105
|
catch {
|
|
@@ -188,6 +199,36 @@ class LayerCache {
|
|
|
188
199
|
}
|
|
189
200
|
return typeof layers === "string" ? undefined : layers;
|
|
190
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether an entry for `file` as its files are now is kept, checked whole but not
|
|
204
|
+
* decompressed; a damaged one is removed.
|
|
205
|
+
*/
|
|
206
|
+
has(file) {
|
|
207
|
+
try {
|
|
208
|
+
const { name, key } = this.identify(file, (0, enc_wasm_1.encStamp)(file));
|
|
209
|
+
const entry = path_1.default.join(this.dir, name);
|
|
210
|
+
const found = entryHead(fs_1.default.readFileSync(entry));
|
|
211
|
+
if (found === "corrupt") {
|
|
212
|
+
fs_1.default.rmSync(entry, { force: true });
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
return found.head.key === key;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Keeps the entries of `files` from being dropped to make room, as if this build used them. */
|
|
222
|
+
hold(files) {
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
try {
|
|
225
|
+
this.used.add(entryName(fs_1.default.realpathSync(file)));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Gone: nothing to keep.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
191
232
|
/**
|
|
192
233
|
* Files the layers just read from `file`, compressed off the event loop; `flush` waits for
|
|
193
234
|
* the write. Layers of a cell whose files changed while it was read are not kept: they are
|
|
@@ -276,19 +317,20 @@ exports.LayerCache = LayerCache;
|
|
|
276
317
|
* from, and filing those it reads. With every cell kept, gdal3.js is not started.
|
|
277
318
|
*/
|
|
278
319
|
function cachedReader(reader, cache) {
|
|
279
|
-
return async (files) => {
|
|
320
|
+
return async (files, onRead) => {
|
|
280
321
|
const out = new Map();
|
|
281
322
|
const read = [];
|
|
282
323
|
for (const file of new Set(files)) {
|
|
283
324
|
const layers = cache.get(file);
|
|
284
325
|
if (layers) {
|
|
285
326
|
out.set(file, layers);
|
|
327
|
+
onRead?.(file);
|
|
286
328
|
}
|
|
287
329
|
else {
|
|
288
330
|
read.push(file);
|
|
289
331
|
}
|
|
290
332
|
}
|
|
291
|
-
for (const [file, layers] of await reader(read)) {
|
|
333
|
+
for (const [file, layers] of await reader(read, onRead)) {
|
|
292
334
|
out.set(file, layers);
|
|
293
335
|
cache.put(file, layers);
|
|
294
336
|
}
|
|
@@ -6,10 +6,11 @@ const worker_threads_1 = require("worker_threads");
|
|
|
6
6
|
const child_process_1 = require("../foundation/child-process");
|
|
7
7
|
const enc_wasm_1 = require("./enc-wasm");
|
|
8
8
|
/** Reads on the calling thread; the WebAssembly memory stays with it. */
|
|
9
|
-
const readInProcess = async (files) => {
|
|
9
|
+
const readInProcess = async (files, onRead) => {
|
|
10
10
|
const out = new Map();
|
|
11
|
-
for (const file of files) {
|
|
11
|
+
for (const file of new Set(files)) {
|
|
12
12
|
out.set(file, await (0, enc_wasm_1.readEncLayers)(file));
|
|
13
|
+
onRead?.(file);
|
|
13
14
|
}
|
|
14
15
|
return out;
|
|
15
16
|
};
|
|
@@ -20,7 +21,7 @@ exports.readInProcess = readInProcess;
|
|
|
20
21
|
* that exits before every cell has come back fails the read.
|
|
21
22
|
*/
|
|
22
23
|
function readInChild(heapMb, script = __filename) {
|
|
23
|
-
return (files) => {
|
|
24
|
+
return (files, onRead) => {
|
|
24
25
|
const want = [...new Set(files)];
|
|
25
26
|
if (want.length === 0) {
|
|
26
27
|
return Promise.resolve(new Map());
|
|
@@ -35,6 +36,7 @@ function readInChild(heapMb, script = __filename) {
|
|
|
35
36
|
}
|
|
36
37
|
else if (m.file !== undefined && m.layers) {
|
|
37
38
|
out.set(m.file, (0, enc_wasm_1.sharedLayers)(new Map(Object.entries(m.layers))));
|
|
39
|
+
onRead?.(m.file);
|
|
38
40
|
}
|
|
39
41
|
});
|
|
40
42
|
child.once("error", (err) => (failure ??= err));
|
package/lib/monitor.js
CHANGED
|
@@ -11,7 +11,7 @@ const notify_1 = require("./engine/notify");
|
|
|
11
11
|
const state_1 = require("./foundation/state");
|
|
12
12
|
const integrity_1 = require("./model/integrity");
|
|
13
13
|
const vessel_1 = require("./model/vessel");
|
|
14
|
-
const
|
|
14
|
+
const box_status_1 = require("./box-status");
|
|
15
15
|
const wfs_1 = require("./ingest/wfs");
|
|
16
16
|
const water_level_service_1 = require("./water-level-service");
|
|
17
17
|
const notifications_1 = require("./notifications");
|
|
@@ -262,7 +262,7 @@ class Monitor {
|
|
|
262
262
|
if (!this.index) {
|
|
263
263
|
return {
|
|
264
264
|
state: "off",
|
|
265
|
-
reason: this.indexReason
|
|
265
|
+
reason: (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.()),
|
|
266
266
|
clearance: null,
|
|
267
267
|
clearanceSigma: null,
|
|
268
268
|
risk: null,
|
|
@@ -331,8 +331,8 @@ class Monitor {
|
|
|
331
331
|
const now = this.clock.now();
|
|
332
332
|
if (!this.index) {
|
|
333
333
|
// The engine is off either way; a pending reason is a status, any other an error.
|
|
334
|
-
const reason = this.indexReason
|
|
335
|
-
if (
|
|
334
|
+
const reason = (0, box_status_1.noIndexReason)(this.indexReason, this.chartBox?.());
|
|
335
|
+
if ((0, box_status_1.isPending)(reason)) {
|
|
336
336
|
this.status.status(boxNote ? `${reason} · ${boxNote}` : reason, now);
|
|
337
337
|
}
|
|
338
338
|
else {
|
package/lib/shared-enc.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.AREA_ERROR_RETRY_MS = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
|
|
6
|
+
exports.AREA_ERROR_RETRY_MS = exports.PROVIDER_ABSENT = exports.ALL_COLLECTIONS = exports.CHARTS_PROVIDER_ID = void 0;
|
|
7
7
|
exports.sharedEncApi = sharedEncApi;
|
|
8
8
|
exports.sharedEncRoot = sharedEncRoot;
|
|
9
9
|
exports.sharedCellsDir = sharedCellsDir;
|
|
@@ -97,14 +97,20 @@ function collectionsHint(api = sharedEncApi()) {
|
|
|
97
97
|
.map((c) => `${c.id} (${c.kind === "chart-set" ? "chart set" : "converted chart"}, ${c.cellCount} cells)`)
|
|
98
98
|
.join(", ")}.`;
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* What is said of a charts provider whose store is not published: at a server start it is
|
|
102
|
+
* published only once the provider's charts are loaded, after this plugin has started.
|
|
103
|
+
*/
|
|
104
|
+
exports.PROVIDER_ABSENT = "Charts Provider is not running or still starting";
|
|
100
105
|
/**
|
|
101
106
|
* Why the charts provider's `collection` holds no cells to read, as it tells it, or that it is
|
|
102
107
|
* not running; undefined when it is ready, or cannot tell. A provider started later restarts
|
|
103
|
-
* the plugin (`watchShared`)
|
|
108
|
+
* the plugin (`watchShared`) unless it shares the cells read at start; then only this note is
|
|
109
|
+
* read again (`watchAtStart` in index.ts).
|
|
104
110
|
*/
|
|
105
111
|
function sharedCellsNote(collection, api = sharedEncApi()) {
|
|
106
112
|
if (!api) {
|
|
107
|
-
return
|
|
113
|
+
return exports.PROVIDER_ABSENT;
|
|
108
114
|
}
|
|
109
115
|
if (collection === exports.ALL_COLLECTIONS) {
|
|
110
116
|
return collectionsHint(api)?.startsWith("Charts Provider shares no")
|
|
@@ -166,18 +172,28 @@ function requestSharedArea(bbox, api = sharedEncApi()) {
|
|
|
166
172
|
accepted: false,
|
|
167
173
|
started: false,
|
|
168
174
|
reason: err.message,
|
|
169
|
-
retryAfterMs: exports.AREA_ERROR_RETRY_MS
|
|
175
|
+
retryAfterMs: exports.AREA_ERROR_RETRY_MS,
|
|
176
|
+
failed: true
|
|
170
177
|
};
|
|
171
178
|
}
|
|
172
179
|
}
|
|
180
|
+
/** A comparison that throws counts as cells changed, so the plugin restarts rather than keep them. */
|
|
181
|
+
function safely(f) {
|
|
182
|
+
try {
|
|
183
|
+
return f();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
173
189
|
/**
|
|
174
190
|
* Calls `onChange` once the cells of `collection` (any, for ALL_COLLECTIONS) changed and
|
|
175
191
|
* settled. While the charts provider has not published its API, looks for it every `pollMs`,
|
|
176
192
|
* and after `waitMs` every `slowPollMs` for as long as the watch runs, since it may start any
|
|
177
193
|
* time; meanwhile the cells are read as found at start. An API that appears later counts as
|
|
178
|
-
* a change
|
|
179
|
-
*
|
|
180
|
-
*
|
|
194
|
+
* a change, unless `sameCells` finds its store holds the cells read at start: the provider
|
|
195
|
+
* may have written cells meanwhile, or keep its store elsewhere than the default path the
|
|
196
|
+
* cells were read from. It is asked once subscribed, so a write after it is heard.
|
|
181
197
|
*/
|
|
182
198
|
function watchSharedCells(collection, clock, onChange, opts = {}) {
|
|
183
199
|
const settleMs = opts.settleMs ?? 5_000;
|
|
@@ -185,6 +201,7 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
|
|
|
185
201
|
const pollMs = opts.pollMs ?? 5_000;
|
|
186
202
|
const slowPollMs = opts.slowPollMs ?? 60_000;
|
|
187
203
|
const started = clock.now();
|
|
204
|
+
const sameAsStart = (root) => safely(() => opts.sameCells?.(root) ?? false);
|
|
188
205
|
let unsubscribe;
|
|
189
206
|
let changedAt;
|
|
190
207
|
let timer;
|
|
@@ -212,7 +229,9 @@ function watchSharedCells(collection, clock, onChange, opts = {}) {
|
|
|
212
229
|
});
|
|
213
230
|
if (late) {
|
|
214
231
|
stopTimer();
|
|
215
|
-
|
|
232
|
+
if (!sameAsStart(api.root)) {
|
|
233
|
+
changed();
|
|
234
|
+
}
|
|
216
235
|
}
|
|
217
236
|
return true;
|
|
218
237
|
};
|
|
@@ -28,9 +28,14 @@ class EncReads {
|
|
|
28
28
|
if (files.length === 0) {
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const total = this.layers.size + new Set(files).size;
|
|
32
|
+
let done = this.layers.size;
|
|
33
|
+
(0, build_progress_1.reportProgress)({ phase: "cells", done, total });
|
|
34
|
+
const read = await this.reader(files, () => {
|
|
35
|
+
done = Math.min(total, done + 1);
|
|
36
|
+
(0, build_progress_1.reportProgress)({ phase: "cells", done, total });
|
|
37
|
+
});
|
|
38
|
+
for (const [file, layers] of read) {
|
|
34
39
|
this.layers.set(file, layers);
|
|
35
40
|
}
|
|
36
41
|
(0, build_progress_1.reportProgress)({ phase: "cells", done: this.layers.size, total: this.layers.size });
|
package/lib/status-report.js
CHANGED
|
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.statusReport = statusReport;
|
|
4
4
|
const public_text_1 = require("./foundation/public-text");
|
|
5
5
|
/** Body of `GET /status`: engine state, index provenance and look-ahead summary; `?summary` without the per-cell lists. */
|
|
6
|
-
function indexSection(load, summary) {
|
|
6
|
+
function indexSection(load, monitor, summary) {
|
|
7
7
|
const index = load?.index;
|
|
8
|
+
// Without an index the engine's reason is why, and follows a first box build as it runs.
|
|
9
|
+
const reason = !index && monitor?.last.state === "off" ? monitor.last.reason : load?.reason;
|
|
8
10
|
return {
|
|
9
11
|
loaded: index !== undefined,
|
|
10
12
|
built: load?.built ?? false,
|
|
11
|
-
reason: (0, public_text_1.publicReason)(
|
|
13
|
+
reason: (0, public_text_1.publicReason)(reason),
|
|
12
14
|
nodes: index?.pyramid.count ?? 0,
|
|
13
15
|
hazards: index?.hazards.size ?? 0,
|
|
14
16
|
...(summary ? {} : { cells: index?.catalogue.cells ?? [] })
|
|
@@ -38,7 +40,7 @@ function statusReport(s) {
|
|
|
38
40
|
const wfs = s.wfs?.status();
|
|
39
41
|
return {
|
|
40
42
|
...(s.monitor ? monitorSection(s.monitor, summary) : { engine: s.stopped, vessel: [] }),
|
|
41
|
-
index: indexSection(s.load, summary),
|
|
43
|
+
index: indexSection(s.load, s.monitor, summary),
|
|
42
44
|
livechart: s.livechart?.status() ?? null,
|
|
43
45
|
wfs: wfs && s.wfs?.downloads ? { ...wfs, lastError: (0, public_text_1.publicReason)(wfs.lastError) } : null,
|
|
44
46
|
chartBox: s.wfs ? boxSection(s.wfs) : null,
|