pmtiles-swarm 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.78.0
11
+ ### ✨ Features and improvements
12
+ - **Somewhere to clear the caches and the files nothing is waiting for.** A merged tile cache with
13
+ no way to empty it is a directory somebody eventually finds by hand, and a temporary file a crash
14
+ left behind is one nobody finds at all. Settings has a **Storage** tab now: what this node is
15
+ holding, what each thing costs to lose, and a button per row.
16
+
17
+ Five things — merged stack tiles, left-over temporary files, stopped exports, traffic history and
18
+ the tile counters. `GET /api/storage` reports them, `DELETE /api/storage/:what` lets go of one.
19
+ Everything on the list is derived and can be rebuilt, which is what makes a button reasonable:
20
+ none of it asks whether you meant it, because none of it is the only copy of anything.
21
+
22
+ The archives are deliberately not on it, and neither is the resume data beside them. Both look
23
+ like housekeeping and neither is: retiring an archive is a decision made from its own panel with
24
+ what it seeds in view, and resume data thrown away is a rehash of every byte on disk.
25
+
26
+ A sweep picks files by name and by age together, because either alone is wrong: `*.tmp` and
27
+ `pmtiles-write-*`, untouched for an hour. Every write that uses one renames within milliseconds,
28
+ so the margin is not for slowness — it is because this runs while the node is serving, and a sweep
29
+ with none at all could take the file a catalog write is halfway through renaming into place.
30
+ `torrents-data` is skipped outright, being terabytes of payload with no working files in it.
31
+
32
+ Stopped exports are read off the disk rather than from what the process remembers, so one left by
33
+ a stack somebody has since deleted is found as well. A running export is left alone: removing the
34
+ directory under a running merge would have it fail on its next write, reporting a disk problem for
35
+ something somebody chose. This is also the first way to discard one without finding the directory
36
+ by hand — the API for it shipped in 0.76.0 with nothing calling it.
37
+
38
+ ### 🐞 Bug fixes
39
+ - _...Add new stuff here..._
40
+
10
41
  ## 0.77.0
11
42
  ### ✨ Features and improvements
12
43
  - **A fade can be written in metres of ground.** `featherMetres` on a source says how far to blend
package/README.md CHANGED
@@ -786,6 +786,8 @@ which the endpoint answers 501.
786
786
  | `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
787
787
  | `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
788
788
  | `GET` `DELETE` | `/api/stats` | What this node has served — per-archive counters and the last N requests; `DELETE` clears them |
789
+ | `GET` | `/api/storage` | What this node is holding that it could let go of — merged tiles, left-over temporary files, stopped exports, traffic history, tile counters |
790
+ | `DELETE` | `/api/storage/:what` | Lets go of one of them. Everything listed is derived and can be rebuilt; the archives are not on the list |
789
791
  | `GET` | `/api/traffic` | Upload and download speed per archive over time, from `stats.db`; `infoHash`, `hours` and `buckets` narrow it, and `totals` ranks archives by bytes moved |
790
792
  | `GET` `PATCH` | `/api/config` | Read and change settings |
791
793
  | `POST` | `/api/login`, `/api/logout` | Console sign-in |
package/docs/internals.md CHANGED
@@ -33,6 +33,7 @@ Operator-facing documentation is elsewhere — see [publishing](publishing.md),
33
33
  - [Retiring old builds](#retiring-old-builds)
34
34
  - [Prewarming a freshly joined archive](#prewarming-a-freshly-joined-archive)
35
35
  - [Named save locations](#named-save-locations)
36
+ - [Storage](#storage)
36
37
 
37
38
  ## Marking incomplete archives
38
39
 
@@ -884,3 +885,60 @@ moving several hundred gigabytes is not something a settings screen should do as
884
885
  a side effect. A move checks free space before the engine is disturbed, since
885
886
  running out halfway through costs an hour, a partial file to clean up, and an
886
887
  archive that has to be put back.
888
+
889
+ ## Storage
890
+
891
+ A merged tile cache with no way to empty it is a directory somebody eventually
892
+ finds by hand, and a temporary file a crash left behind is one nobody finds at
893
+ all. `GET /api/storage` reports what a node is holding that it could let go of;
894
+ `DELETE /api/storage/:what` lets go of one of them. The console draws it as the
895
+ Storage tab under Settings.
896
+
897
+ Five things, and what each costs to lose:
898
+
899
+ | What | Where | Clearing it costs |
900
+ | ------------------------- | ------------------ | -------------------------------------------------------------- |
901
+ | Merged stack tiles | `stack-cache/` | The merge again: an archive read per source, and a decode each |
902
+ | Left-over temporary files | the data directory | Nothing — nobody is waiting for a file a crashed write left |
903
+ | Stopped exports | the archive's disk | The hours already spent, which is what resuming would use |
904
+ | Traffic history | `stats.db` | The graphs go back to empty |
905
+ | Tile counters | memory | The count starts again; frees nothing |
906
+
907
+ Everything on that list is derived and can be rebuilt, which is what makes a
908
+ button reasonable — none of it asks whether the operator meant it, because none
909
+ of it is the only copy of anything.
910
+
911
+ ### What is deliberately not on it
912
+
913
+ **The archives**, and the resume data beside them. Both look like housekeeping
914
+ and neither is. An archive is the data this node exists to serve, and retiring
915
+ one is a decision made from its own panel with what it seeds in view. Resume
916
+ data thrown away is a rehash of every byte on disk — and worse than useless as
917
+ a repair, since a _stale partial_ resume file is the thing that cancels seed
918
+ mode, which is a diagnosis rather than a sweep.
919
+
920
+ ### Which files a sweep is willing to touch
921
+
922
+ By name and by age together, because either alone is wrong: the name says what
923
+ a file was for and the age says whether anything still cares.
924
+
925
+ The names are `*.tmp` and `pmtiles-write-*`, which is what the write-then-rename
926
+ in `catalog.js`, `stacks.js`, `dht-state.js`, `stack-cache.js` and
927
+ `pmtiles-write.js` leaves behind when a process stops between the two steps. The
928
+ age is an hour. Every one of those renames happens within milliseconds, so an
929
+ hour is far past generous — the margin is not for slowness, it is because this
930
+ runs while the node is serving and a sweep with no margin could delete the file
931
+ a catalog write is halfway through renaming into place.
932
+
933
+ `torrents-data/` is skipped outright. It is terabytes of payload, nothing in it
934
+ is a working file, and a sweep that walks it is a sweep that spends minutes
935
+ finding nothing.
936
+
937
+ ### The bake working directories are read from disk
938
+
939
+ Not from what the process remembers. A working directory outlives the run that
940
+ made it, and one belonging to a stack somebody has since deleted is exactly the
941
+ kind nobody thinks to look for. Whether an export is _running_ is memory's to
942
+ say, and that is the only thing that decides whether it may be discarded —
943
+ removing the directory under a running merge would have it fail on its next
944
+ write, reporting a disk problem for something somebody chose.
@@ -1299,9 +1299,17 @@ mostly diagonals.
1299
1299
  works out the pixels for each tile it builds:
1300
1300
 
1301
1301
  ```json
1302
- { "archive": "gebco", "maskRange": [-1, 0], "featherMetres": 50 }
1302
+ { "category": "planet-bathymetry", "maskRange": [-1, 0], "featherMetres": 50 }
1303
1303
  ```
1304
1304
 
1305
+ That is the source **on top** — the one whose mask leaves the hole. A weight
1306
+ says how much of what is underneath shows through, so it goes on the layer with
1307
+ the edge and not on the one filling the hole: a fade on the bathymetry
1308
+ underneath is read by nothing, because `paintHeights` takes the first layer that
1309
+ contributes whole and only consults a weight from the second onward. See "A
1310
+ feathered layer over a hole stands alone" above, which is the same rule seen
1311
+ from the other side.
1312
+
1305
1313
  Web Mercator makes that arithmetic rather than a lookup: one pixel covers
1306
1314
  `40075016.686 × cos(latitude) / 2^zoom / tileSize` metres, so the conversion
1307
1315
  needs a multiply and the coordinates of the tile being built. `featherMeters` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api.js CHANGED
@@ -37,6 +37,7 @@ import { SUMMARY_VERSION } from './pmtiles-probe.js';
37
37
  import { TileReadError } from './tiles.js';
38
38
  import { loadCodec } from './codec.js';
39
39
  import { answerStackTile, outputFormat, outputSize } from './stack-tile.js';
40
+ import { clearStorage, storageReport } from './storage.js';
40
41
  import {
41
42
  isPinned,
42
43
  needsCodec,
@@ -790,6 +791,38 @@ export function createApp({
790
791
  }),
791
792
  );
792
793
 
794
+ // What this node is holding that it could let go of. Separate from the
795
+ // settings it sits beside in the console: a setting says what to do next and
796
+ // this says what to do about what was already done.
797
+ app.get(
798
+ '/api/storage',
799
+ route(async (_req, res) => {
800
+ res.setHeader('cache-control', 'no-store');
801
+ res.json(
802
+ await storageReport({ config, stackCache, stats, traffic, bakes }),
803
+ );
804
+ }),
805
+ );
806
+
807
+ app.delete(
808
+ '/api/storage/:what',
809
+ route(async (req, res) => {
810
+ const gone = await clearStorage(req.params.what, {
811
+ config,
812
+ stackCache,
813
+ stats,
814
+ traffic,
815
+ bakes,
816
+ });
817
+ if (!gone) {
818
+ return res
819
+ .status(404)
820
+ .json({ error: 'nothing here is called that, or it is turned off' });
821
+ }
822
+ return res.json(gone);
823
+ }),
824
+ );
825
+
793
826
  // Settings. Everything read per request takes effect immediately because the
794
827
  // running config object is the one being mutated; everything bound at startup
795
828
  // is written to the file and reported back as needing a restart, rather than
package/src/bake-jobs.js CHANGED
@@ -205,6 +205,34 @@ export class BakeManager {
205
205
  return true;
206
206
  }
207
207
 
208
+ /**
209
+ * Every export with work still on disk, and where it is.
210
+ *
211
+ * Read from the filesystem rather than from what this process remembers: a
212
+ * working directory outlives the run that made it, and one belonging to a
213
+ * stack somebody has since deleted is exactly the kind nobody thinks to look
214
+ * for. Whether it is running is what decides if it may be discarded, and
215
+ * that is memory's to say.
216
+ * @returns {Promise<object[]>} - `{stackId, directory, running, stopped}`.
217
+ */
218
+ async heldWork() {
219
+ const found = [];
220
+ for (const root of this.#workRoots()) {
221
+ const base = path.join(root, WORK_DIR);
222
+ const names = await fs.readdir(base).catch(() => []);
223
+ for (const stackId of names) {
224
+ const job = this.#jobs.get(stackId);
225
+ found.push({
226
+ stackId,
227
+ directory: path.join(base, stackId),
228
+ running: Boolean(job && !job.finishedAt),
229
+ stopped: this.#held.has(stackId),
230
+ });
231
+ }
232
+ }
233
+ return found;
234
+ }
235
+
208
236
  /**
209
237
  * Throws away what a stopped export had done.
210
238
  *
@@ -207,6 +207,25 @@ export class StackCache {
207
207
  }
208
208
  }
209
209
 
210
+ /**
211
+ * Throws away every tile it is holding.
212
+ *
213
+ * The hit and miss counters are left alone: they say what the cache has been
214
+ * doing since the process started, which emptying it does not unmake. The
215
+ * files go one at a time and forgivingly -- one that vanished underneath is
216
+ * one fewer to remove.
217
+ * @returns {Promise<number>} - How many tiles went.
218
+ */
219
+ async clear() {
220
+ const keys = [...this.#entries.keys()];
221
+ this.#entries.clear();
222
+ this.#bytes = 0;
223
+ for (const key of keys) {
224
+ await fs.rm(this.#pathFor(key), { force: true }).catch(() => {});
225
+ }
226
+ return keys.length;
227
+ }
228
+
210
229
  /**
211
230
  * What the cache is holding, for the console and for tests.
212
231
  * @returns {object} - entries, bytes, maxBytes, hits, misses.
package/src/storage.js ADDED
@@ -0,0 +1,283 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * What this node is holding that it could let go of.
6
+ *
7
+ * Everything here is derived and can be rebuilt: a merged tile can be merged
8
+ * again, a traffic sample can be taken again, a half-finished write is a file
9
+ * nobody is waiting for. That is what makes a button reasonable — none of it
10
+ * asks whether the operator is sure they meant it, because none of it is the
11
+ * only copy of anything.
12
+ *
13
+ * The archives themselves are deliberately absent, and so is the resume data
14
+ * beside them. Both look like housekeeping and neither is: an archive is the
15
+ * data this node exists to serve, and resume data thrown away is a rehash of
16
+ * every byte on disk. Retiring an archive is its own decision, made where the
17
+ * archive is, with what it seeds in view. See docs/internals.md — "Storage".
18
+ */
19
+
20
+ /**
21
+ * How long a temporary file has to sit there before it is left behind.
22
+ *
23
+ * Every write that uses one renames within milliseconds, so an hour is far
24
+ * past generous. The margin is not for slowness, it is because this runs while
25
+ * the node is serving: a sweep with no margin at all could delete the file a
26
+ * catalog write is in the middle of, on the one machine where it matters.
27
+ */
28
+ const STALE_AFTER = 60 * 60 * 1000;
29
+
30
+ /** What a half-finished write leaves behind, by name. */
31
+ const TEMPORARY = [/\.tmp$/, /\.\d+\.tmp$/, /^pmtiles-write-/];
32
+
33
+ /**
34
+ * Directories under the data directory that hold archives rather than working
35
+ * files. Skipped so a sweep cannot wander into terabytes of payload looking
36
+ * for kilobytes of leftovers.
37
+ */
38
+ const PAYLOAD = new Set(['torrents-data']);
39
+
40
+ /**
41
+ * How big a file is, or 0 when it is not there any more.
42
+ * @param {string} file - Its path.
43
+ * @returns {Promise<number>} - Bytes.
44
+ */
45
+ async function sizeOf(file) {
46
+ const info = await fs.stat(file).catch(() => null);
47
+ return info?.isFile() ? info.size : 0;
48
+ }
49
+
50
+ /**
51
+ * How much a directory holds, following it down.
52
+ *
53
+ * Errors are swallowed per entry rather than per walk: a report that says
54
+ * nothing because one file disappeared while it was being counted is worse
55
+ * than one that is a few kilobytes out.
56
+ * @param {string} dir - Where to start.
57
+ * @returns {Promise<object>} - `{bytes, files}`.
58
+ */
59
+ export async function directorySize(dir) {
60
+ let bytes = 0;
61
+ let files = 0;
62
+ const entries = await fs
63
+ .readdir(dir, { withFileTypes: true })
64
+ .catch(() => []);
65
+ for (const entry of entries) {
66
+ const here = path.join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ const under = await directorySize(here);
69
+ bytes += under.bytes;
70
+ files += under.files;
71
+ } else {
72
+ bytes += await sizeOf(here);
73
+ files += 1;
74
+ }
75
+ }
76
+ return { bytes, files };
77
+ }
78
+
79
+ /**
80
+ * Temporary files nobody is writing any more.
81
+ *
82
+ * Found by name and by age together, because either alone is wrong: a name
83
+ * says what a file was for and an age says whether anything still cares.
84
+ * @param {string} dir - Where to look.
85
+ * @param {number} [now] - The clock, injectable for tests.
86
+ * @returns {Promise<object[]>} - `{path, bytes}` per file.
87
+ */
88
+ export async function staleTemporaries(dir, now = Date.now()) {
89
+ const found = [];
90
+ const entries = await fs
91
+ .readdir(dir, { withFileTypes: true })
92
+ .catch(() => []);
93
+
94
+ for (const entry of entries) {
95
+ const here = path.join(dir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ if (PAYLOAD.has(entry.name)) continue;
98
+ found.push(...(await staleTemporaries(here, now)));
99
+ continue;
100
+ }
101
+ if (!TEMPORARY.some((pattern) => pattern.test(entry.name))) continue;
102
+ const info = await fs.stat(here).catch(() => null);
103
+ if (!info || now - info.mtimeMs < STALE_AFTER) continue;
104
+ found.push({ path: here, bytes: info.size });
105
+ }
106
+ return found;
107
+ }
108
+
109
+ /**
110
+ * Everything a node is holding that it could be asked to let go of.
111
+ *
112
+ * Reported whether or not there is anything in it, so the panel reads the same
113
+ * on a node that has just started as on one that has been running for a month
114
+ * — a row that appears only once it has something to say is a row nobody knows
115
+ * to look for.
116
+ * @param {object} deps - config, stackCache, stats, traffic, bakes.
117
+ * @returns {Promise<object>} - `{items}`, each with an id, a size and a note.
118
+ */
119
+ export async function storageReport({
120
+ config,
121
+ stackCache,
122
+ stats,
123
+ traffic,
124
+ bakes,
125
+ } = {}) {
126
+ const dataDir = config?.dataDir ?? './data';
127
+ const items = [];
128
+
129
+ const cache = stackCache?.stats?.() ?? null;
130
+ items.push({
131
+ id: 'merged-tiles',
132
+ title: 'Merged stack tiles',
133
+ where: path.join(dataDir, 'stack-cache'),
134
+ bytes: cache?.bytes ?? 0,
135
+ count: cache?.entries ?? 0,
136
+ unit: 'tiles',
137
+ available: Boolean(cache?.enabled),
138
+ note: cache?.enabled
139
+ ? 'Tiles a stack has already merged. Clearing them costs the merge again ' +
140
+ 'the next time each is asked for — one archive read per source, a ' +
141
+ 'decode each, and the merge itself.'
142
+ : 'Off: stacks.cacheBytes is 0, so nothing is kept.',
143
+ });
144
+
145
+ const temporaries = await staleTemporaries(dataDir);
146
+ items.push({
147
+ id: 'temporary',
148
+ title: 'Left-over temporary files',
149
+ where: dataDir,
150
+ bytes: temporaries.reduce((sum, file) => sum + file.bytes, 0),
151
+ count: temporaries.length,
152
+ unit: 'files',
153
+ available: true,
154
+ note:
155
+ 'Half-finished writes, from a process that stopped between writing a ' +
156
+ 'file and renaming it into place. Only ones untouched for an hour are ' +
157
+ 'counted, so a write happening right now is never one of them.',
158
+ });
159
+
160
+ const work = (await bakes?.heldWork?.()) ?? [];
161
+ const sized = await Promise.all(
162
+ work.map(async (held) => ({
163
+ ...held,
164
+ ...(await directorySize(held.directory)),
165
+ })),
166
+ );
167
+ const idle = sized.filter((held) => !held.running);
168
+ items.push({
169
+ id: 'stopped-exports',
170
+ title: 'Stopped exports',
171
+ where: idle.map((held) => held.directory).join(', '),
172
+ bytes: idle.reduce((sum, held) => sum + held.bytes, 0),
173
+ count: idle.length,
174
+ unit: 'exports',
175
+ available: idle.length > 0,
176
+ stacks: idle.map((held) => held.stackId),
177
+ note:
178
+ 'Tiles an export had buffered before it was stopped. Keeping them is ' +
179
+ 'what lets it carry on rather than start again, so this is only worth ' +
180
+ 'clearing for an export nobody is going to finish.',
181
+ });
182
+
183
+ const trafficFile = path.join(dataDir, 'stats.db');
184
+ items.push({
185
+ id: 'traffic-history',
186
+ title: 'Traffic history',
187
+ where: trafficFile,
188
+ bytes: await sizeOf(trafficFile),
189
+ count: null,
190
+ available: Boolean(traffic),
191
+ note:
192
+ 'Per-archive upload and download samples, behind the traffic graphs. ' +
193
+ 'It already drops anything past its retention, so this is for reclaiming ' +
194
+ 'the file rather than for keeping it in bounds.',
195
+ });
196
+
197
+ items.push({
198
+ id: 'tile-counters',
199
+ title: 'Tile counters',
200
+ where: 'memory',
201
+ bytes: 0,
202
+ count: stats?.snapshot?.()?.total ?? null,
203
+ unit: 'requests',
204
+ available: Boolean(stats),
205
+ note:
206
+ 'How many tiles each archive has served, since the counters were last ' +
207
+ 'reset. Held in memory, so this frees nothing — it starts the count ' +
208
+ 'again.',
209
+ });
210
+
211
+ return {
212
+ dataDir,
213
+ items,
214
+ bytes: items.reduce((sum, item) => sum + (item.bytes ?? 0), 0),
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Lets go of one of them.
220
+ * @param {string} what - The id of an item in the report.
221
+ * @param {object} deps - config, stackCache, stats, traffic, bakes.
222
+ * @returns {Promise<object|null>} - What went, or null for an id nothing knows.
223
+ */
224
+ export async function clearStorage(what, deps = {}) {
225
+ const { config, stackCache, stats, traffic, bakes } = deps;
226
+
227
+ if (what === 'merged-tiles') {
228
+ const before = stackCache?.stats?.()?.bytes ?? 0;
229
+ const cleared = (await stackCache?.clear?.()) ?? 0;
230
+ return { cleared, bytes: before };
231
+ }
232
+
233
+ if (what === 'temporary') {
234
+ const files = await staleTemporaries(config?.dataDir ?? './data');
235
+ let bytes = 0;
236
+ let cleared = 0;
237
+ for (const file of files) {
238
+ // One at a time and forgiving: a file that vanished between the walk and
239
+ // the unlink is a file that is gone, which is what was wanted.
240
+ const removed = await fs
241
+ .rm(file.path, { force: true })
242
+ .then(() => true)
243
+ .catch(() => false);
244
+ if (!removed) continue;
245
+ bytes += file.bytes;
246
+ cleared += 1;
247
+ }
248
+ return { cleared, bytes };
249
+ }
250
+
251
+ if (what === 'stopped-exports') {
252
+ const work = (await bakes?.heldWork?.()) ?? [];
253
+ let bytes = 0;
254
+ let cleared = 0;
255
+ for (const held of work) {
256
+ if (held.running) continue;
257
+ const size = await directorySize(held.directory);
258
+ // Asked of the manager rather than removed here, so an export that
259
+ // started between the report and the button is refused by the same rule
260
+ // that refuses it everywhere else.
261
+ if (!(await bakes.discard(held.stackId))) continue;
262
+ bytes += size.bytes;
263
+ cleared += 1;
264
+ }
265
+ return { cleared, bytes };
266
+ }
267
+
268
+ if (what === 'traffic-history') {
269
+ if (!traffic?.clear) return null;
270
+ const file = path.join(config?.dataDir ?? './data', 'stats.db');
271
+ const before = await sizeOf(file);
272
+ traffic.clear();
273
+ return { cleared: 1, bytes: before - (await sizeOf(file)) };
274
+ }
275
+
276
+ if (what === 'tile-counters') {
277
+ if (!stats?.reset) return null;
278
+ stats.reset();
279
+ return { cleared: 1, bytes: 0 };
280
+ }
281
+
282
+ return null;
283
+ }
@@ -143,6 +143,18 @@ export class TrafficStats {
143
143
  return written;
144
144
  }
145
145
 
146
+ /**
147
+ * Throws away every sample, and the file they were in.
148
+ *
149
+ * Vacuumed rather than only deleted: deleting rows returns the pages to
150
+ * sqlite's free list, and somebody clearing this wants the disk back.
151
+ * @returns {void}
152
+ */
153
+ clear() {
154
+ this.#db.prepare('DELETE FROM traffic').run();
155
+ this.#db.exec('VACUUM');
156
+ }
157
+
146
158
  /**
147
159
  * Deletes samples that have aged past the retention window.
148
160
  * @returns {number} - Rows removed.
@@ -4823,6 +4823,100 @@ Every piece is hashed against the ` +
4823
4823
  * @param {HTMLElement} into - Where to render.
4824
4824
  * @returns {Promise<void>} - Resolves once rendered.
4825
4825
  */
4826
+ /**
4827
+ * What this node is holding that it could let go of.
4828
+ *
4829
+ * Everything listed here is derived and can be rebuilt, which is why
4830
+ * each row is one button and not a dialog: a merged tile can be merged
4831
+ * again, a traffic sample taken again, a half-finished write is a file
4832
+ * nobody is waiting for. The archives are deliberately not here, and
4833
+ * neither is the resume data beside them -- retiring an archive is its
4834
+ * own decision, made where the archive is.
4835
+ * @param {HTMLElement} into - The settings pane to render in.
4836
+ * @returns {Promise<void>} - Resolves once it is drawn.
4837
+ */
4838
+ async function renderStorageEditor(into) {
4839
+ const panel = document.createElement('div');
4840
+ panel.className = 'panel';
4841
+ panel.style.marginBottom = '1rem';
4842
+ into.append(panel);
4843
+
4844
+ const draw = async () => {
4845
+ let state;
4846
+ try {
4847
+ state = await api('/api/storage');
4848
+ } catch (error) {
4849
+ panel.innerHTML = `<div class="empty">${escapeHtml(error.message)}</div>`;
4850
+ return;
4851
+ }
4852
+
4853
+ const rows = state.items
4854
+ .map((item) => {
4855
+ const size =
4856
+ item.bytes > 0 ? bytes(item.bytes) : item.available ? 'empty' : EMPTY;
4857
+ const held =
4858
+ Number.isFinite(item.count) && item.count > 0
4859
+ ? `<div class="sub">${item.count.toLocaleString()} ${escapeHtml(item.unit ?? '')}</div>`
4860
+ : '';
4861
+ return `
4862
+ <tr>
4863
+ <td>
4864
+ <b>${escapeHtml(item.title)}</b>
4865
+ <div class="sub">${escapeHtml(item.note)}</div>
4866
+ <div class="sub"><code>${escapeHtml(item.where || '')}</code></div>
4867
+ </td>
4868
+ <td style="white-space:nowrap">${size}${held}</td>
4869
+ <td style="text-align:right">
4870
+ <button data-clear="${escapeHtml(item.id)}"
4871
+ ${item.available && (item.bytes > 0 || item.count > 0) ? '' : 'disabled'}>
4872
+ Clear
4873
+ </button>
4874
+ </td>
4875
+ </tr>`;
4876
+ })
4877
+ .join('');
4878
+
4879
+ panel.innerHTML = `
4880
+ <h2 style="margin-top:0">Storage</h2>
4881
+ <div class="sub" style="margin-bottom:0.8rem">
4882
+ Everything here can be rebuilt, so none of it asks whether you
4883
+ meant it. The archives themselves are not here: retiring one is
4884
+ its own decision, made from its own panel, with what it seeds in
4885
+ view. Holding <b>${bytes(state.bytes) === EMPTY ? 'nothing' : bytes(state.bytes)}</b>
4886
+ under <code>${escapeHtml(state.dataDir)}</code>.
4887
+ </div>
4888
+ <table>
4889
+ <tbody>${rows}</tbody>
4890
+ </table>
4891
+ <div class="sub" id="storage-note" style="margin-top:0.6rem"></div>`;
4892
+
4893
+ for (const button of panel.querySelectorAll('[data-clear]')) {
4894
+ button.onclick = async () => {
4895
+ const what = button.dataset.clear;
4896
+ button.disabled = true;
4897
+ try {
4898
+ const gone = await api(`/api/storage/${what}`, {
4899
+ method: 'DELETE',
4900
+ });
4901
+ await draw();
4902
+ const note = panel.querySelector('#storage-note');
4903
+ if (note) {
4904
+ note.textContent = gone.bytes
4905
+ ? `Freed ${bytes(gone.bytes)}.`
4906
+ : 'Cleared.';
4907
+ }
4908
+ } catch (error) {
4909
+ const note = panel.querySelector('#storage-note');
4910
+ if (note) note.textContent = error.message;
4911
+ button.disabled = false;
4912
+ }
4913
+ };
4914
+ }
4915
+ };
4916
+
4917
+ await draw();
4918
+ }
4919
+
4826
4920
  async function renderTokenEditor(into) {
4827
4921
  const panel = document.createElement('div');
4828
4922
  panel.className = 'panel';
@@ -5044,6 +5138,7 @@ Every piece is hashed against the ` +
5044
5138
 
5045
5139
  const SETTINGS_GROUPS = [
5046
5140
  'Serving tiles',
5141
+ 'Storage',
5047
5142
  'Feeds',
5048
5143
  'Publishing',
5049
5144
  'Network',
@@ -6323,6 +6418,7 @@ Every piece is hashed against the ` +
6323
6418
  // Tokens are credentials, so they sit with the rest of who may reach
6324
6419
  // this node. Hooks run a command when a download finishes, which is
6325
6420
  // what the Transfers tab is about.
6421
+ await renderStorageEditor(paneFor('Storage'));
6326
6422
  await renderTokenEditor(paneFor('Security'));
6327
6423
  renderHookEditor(paneFor('Transfers'), config, restartKeys, true);
6328
6424
 
@@ -7737,9 +7833,11 @@ Every piece is hashed against the ` +
7737
7833
  }>px</option>
7738
7834
  </select>
7739
7835
  <span class="sub">${
7740
- featherWidth && !fadesAt
7741
- ? 'nothing to fade at yet: mask something, or clip it'
7742
- : ''
7836
+ featherWidth && index === 0 && !rgba
7837
+ ? 'the bottom source has nothing underneath to fade into'
7838
+ : featherWidth && !fadesAt
7839
+ ? 'nothing to fade at yet: mask something, or clip it'
7840
+ : ''
7743
7841
  }</span>
7744
7842
  </label>
7745
7843
  ${