pmtiles-swarm 0.77.0 → 0.79.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,67 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.79.0
11
+ ### ✨ Features and improvements
12
+ - **Duplicate a stack.** A button beside Edit on every row, which opens the editor on a copy of that
13
+ recipe: the same sources in the same order, the same masks, the same output, under a name of its
14
+ own and saved only when you save it. Most stacks after the first are a variation on one that
15
+ already works, and rebuilding that by hand is where a source gets left out.
16
+
17
+ Two things are changed for the copy and nothing else is. The name becomes `<name>-copy`, counting
18
+ up until it is one nothing is using, and the title gains `copy` — two stacks under one title are
19
+ two rows nobody can tell apart in the list they both appear in. Both are editable before saving.
20
+
21
+ It copies the **recipe** rather than the row. The list holds what each source resolved to, so a
22
+ copy taken from it would pin the infohashes the original follows by category and stop following
23
+ rebuilds from the moment it was made.
24
+
25
+ ### 🐞 Bug fixes
26
+ - **Saving a stack with no name reported that the reply was not JSON.** `Unexpected token '<',
27
+ "<!DOCTYPE "... is not valid JSON`, which says nothing about a missing name. A stack with no name
28
+ is a `PUT /api/stacks/`, and that matches no route at all — `:id` needs a segment to be — so what
29
+ came back was express's own HTML error page, which the console then tried to parse.
30
+
31
+ Both halves are fixed. Every unmatched path under `/api` answers JSON now, so any future typo says
32
+ `no route for PUT /api/stacks/` rather than arriving as a parse error; and the dialog refuses an
33
+ empty or malformed name itself, since it is the one that knows what the box is for.
34
+
35
+ - **A new stack could be saved over an existing one without a word.** `PUT` upserts, which is right
36
+ for editing and wrong for naming: typing a name already in use replaced that stack rather than
37
+ refusing. It is refused now when naming a new stack or a copy — where the stack it would have
38
+ replaced is usually the one being copied. Editing is unaffected; a stack keeps the name it has.
39
+
40
+ ## 0.78.0
41
+ ### ✨ Features and improvements
42
+ - **Somewhere to clear the caches and the files nothing is waiting for.** A merged tile cache with
43
+ no way to empty it is a directory somebody eventually finds by hand, and a temporary file a crash
44
+ left behind is one nobody finds at all. Settings has a **Storage** tab now: what this node is
45
+ holding, what each thing costs to lose, and a button per row.
46
+
47
+ Five things — merged stack tiles, left-over temporary files, stopped exports, traffic history and
48
+ the tile counters. `GET /api/storage` reports them, `DELETE /api/storage/:what` lets go of one.
49
+ Everything on the list is derived and can be rebuilt, which is what makes a button reasonable:
50
+ none of it asks whether you meant it, because none of it is the only copy of anything.
51
+
52
+ The archives are deliberately not on it, and neither is the resume data beside them. Both look
53
+ like housekeeping and neither is: retiring an archive is a decision made from its own panel with
54
+ what it seeds in view, and resume data thrown away is a rehash of every byte on disk.
55
+
56
+ A sweep picks files by name and by age together, because either alone is wrong: `*.tmp` and
57
+ `pmtiles-write-*`, untouched for an hour. Every write that uses one renames within milliseconds,
58
+ so the margin is not for slowness — it is because this runs while the node is serving, and a sweep
59
+ with none at all could take the file a catalog write is halfway through renaming into place.
60
+ `torrents-data` is skipped outright, being terabytes of payload with no working files in it.
61
+
62
+ Stopped exports are read off the disk rather than from what the process remembers, so one left by
63
+ a stack somebody has since deleted is found as well. A running export is left alone: removing the
64
+ directory under a running merge would have it fail on its next write, reporting a disk problem for
65
+ something somebody chose. This is also the first way to discard one without finding the directory
66
+ by hand — the API for it shipped in 0.76.0 with nothing calling it.
67
+
68
+ ### 🐞 Bug fixes
69
+ - _...Add new stuff here..._
70
+
10
71
  ## 0.77.0
11
72
  ### ✨ Features and improvements
12
73
  - **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
@@ -1578,10 +1586,33 @@ editor exists for — an editor whose primary action only works with a mouse is
1578
1586
  an editor half the people cannot use. The console has no drag-and-drop anywhere
1579
1587
  yet, so this is the first, and the buttons are what make it safe to add.
1580
1588
 
1589
+ ### Starting from a stack that already works
1590
+
1591
+ **Duplicate**, beside Edit on every row. It opens the editor on a copy of that
1592
+ recipe: the same sources in the same order, the same masks, the same output —
1593
+ under a name of its own, and saved only when it is saved. Most stacks after the
1594
+ first are a variation on one that already works, and rebuilding that by hand is
1595
+ where a source gets left out.
1596
+
1597
+ Two things are changed for the copy and nothing else is. The name becomes
1598
+ `<name>-copy`, counting up until it is one nothing is using; the title gains
1599
+ `copy`, because two stacks under one title are two rows nobody can tell apart in
1600
+ the list they both appear in. Both are editable before saving — the point is
1601
+ that neither is left matching by accident.
1602
+
1603
+ It copies the **recipe** rather than the row. The list holds what each source
1604
+ resolved to, so a copy taken from it would pin the infohashes the original
1605
+ follows by category, and stop following rebuilds from the moment it was made.
1606
+ The editor reads `/api/stacks/<id>/raw` for the same reason.
1607
+
1581
1608
  ### What the editor refuses, and what it only warns about
1582
1609
 
1583
1610
  Refuses, because the stack cannot work:
1584
1611
 
1612
+ - A name that is already a stack's, when naming a new one or a copy. `PUT`
1613
+ upserts, so this is the difference between adding a stack and replacing one —
1614
+ and for a copy, the stack it would replace is usually the one being copied.
1615
+ Editing an existing stack is unaffected: it keeps the name it has.
1585
1616
  - A recipe naming an `output.tileSize` that is not 256 or 512.
1586
1617
  - A source that resolves to nothing — an empty category, or an archive that
1587
1618
  retention has removed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.77.0",
3
+ "version": "0.79.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
@@ -3725,6 +3758,19 @@ export function createApp({
3725
3758
  }
3726
3759
  }
3727
3760
 
3761
+ // An API path no route claimed. Without this express's own handler answers
3762
+ // with an HTML error page, and a caller that parses every reply as JSON --
3763
+ // the console does -- reports `Unexpected token '<'`, which says nothing at
3764
+ // all about what was wrong with the request.
3765
+ //
3766
+ // The one that produced it was a stack saved with no name: `PUT
3767
+ // /api/stacks/` matches no route, because `:id` needs a segment to be, and
3768
+ // the reply came back as the start of an HTML document.
3769
+ app.use('/api', (req, res) => {
3770
+ const where = req.originalUrl.split('?')[0];
3771
+ res.status(404).json({ error: `no route for ${req.method} ${where}` });
3772
+ });
3773
+
3728
3774
  app.use(express.static(path.join(here, 'web')));
3729
3775
 
3730
3776
  // Four parameters, two of them unused: express identifies an error handler
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.
@@ -840,7 +840,8 @@
840
840
 
841
841
  <div class="field">
842
842
  <label for="stack-id">Name</label>
843
- <input id="stack-id" placeholder="planet-terrain" autocomplete="off" />
843
+ <input id="stack-id" placeholder="planet-terrain" autocomplete="off"
844
+ required pattern="[A-Za-z0-9][A-Za-z0-9._-]*" />
844
845
  <div class="sub">
845
846
  Used in the URL: <code id="stack-url-preview">/stacks/…/tiles.json</code>
846
847
  </div>
@@ -4823,6 +4824,100 @@ Every piece is hashed against the ` +
4823
4824
  * @param {HTMLElement} into - Where to render.
4824
4825
  * @returns {Promise<void>} - Resolves once rendered.
4825
4826
  */
4827
+ /**
4828
+ * What this node is holding that it could let go of.
4829
+ *
4830
+ * Everything listed here is derived and can be rebuilt, which is why
4831
+ * each row is one button and not a dialog: a merged tile can be merged
4832
+ * again, a traffic sample taken again, a half-finished write is a file
4833
+ * nobody is waiting for. The archives are deliberately not here, and
4834
+ * neither is the resume data beside them -- retiring an archive is its
4835
+ * own decision, made where the archive is.
4836
+ * @param {HTMLElement} into - The settings pane to render in.
4837
+ * @returns {Promise<void>} - Resolves once it is drawn.
4838
+ */
4839
+ async function renderStorageEditor(into) {
4840
+ const panel = document.createElement('div');
4841
+ panel.className = 'panel';
4842
+ panel.style.marginBottom = '1rem';
4843
+ into.append(panel);
4844
+
4845
+ const draw = async () => {
4846
+ let state;
4847
+ try {
4848
+ state = await api('/api/storage');
4849
+ } catch (error) {
4850
+ panel.innerHTML = `<div class="empty">${escapeHtml(error.message)}</div>`;
4851
+ return;
4852
+ }
4853
+
4854
+ const rows = state.items
4855
+ .map((item) => {
4856
+ const size =
4857
+ item.bytes > 0 ? bytes(item.bytes) : item.available ? 'empty' : EMPTY;
4858
+ const held =
4859
+ Number.isFinite(item.count) && item.count > 0
4860
+ ? `<div class="sub">${item.count.toLocaleString()} ${escapeHtml(item.unit ?? '')}</div>`
4861
+ : '';
4862
+ return `
4863
+ <tr>
4864
+ <td>
4865
+ <b>${escapeHtml(item.title)}</b>
4866
+ <div class="sub">${escapeHtml(item.note)}</div>
4867
+ <div class="sub"><code>${escapeHtml(item.where || '')}</code></div>
4868
+ </td>
4869
+ <td style="white-space:nowrap">${size}${held}</td>
4870
+ <td style="text-align:right">
4871
+ <button data-clear="${escapeHtml(item.id)}"
4872
+ ${item.available && (item.bytes > 0 || item.count > 0) ? '' : 'disabled'}>
4873
+ Clear
4874
+ </button>
4875
+ </td>
4876
+ </tr>`;
4877
+ })
4878
+ .join('');
4879
+
4880
+ panel.innerHTML = `
4881
+ <h2 style="margin-top:0">Storage</h2>
4882
+ <div class="sub" style="margin-bottom:0.8rem">
4883
+ Everything here can be rebuilt, so none of it asks whether you
4884
+ meant it. The archives themselves are not here: retiring one is
4885
+ its own decision, made from its own panel, with what it seeds in
4886
+ view. Holding <b>${bytes(state.bytes) === EMPTY ? 'nothing' : bytes(state.bytes)}</b>
4887
+ under <code>${escapeHtml(state.dataDir)}</code>.
4888
+ </div>
4889
+ <table>
4890
+ <tbody>${rows}</tbody>
4891
+ </table>
4892
+ <div class="sub" id="storage-note" style="margin-top:0.6rem"></div>`;
4893
+
4894
+ for (const button of panel.querySelectorAll('[data-clear]')) {
4895
+ button.onclick = async () => {
4896
+ const what = button.dataset.clear;
4897
+ button.disabled = true;
4898
+ try {
4899
+ const gone = await api(`/api/storage/${what}`, {
4900
+ method: 'DELETE',
4901
+ });
4902
+ await draw();
4903
+ const note = panel.querySelector('#storage-note');
4904
+ if (note) {
4905
+ note.textContent = gone.bytes
4906
+ ? `Freed ${bytes(gone.bytes)}.`
4907
+ : 'Cleared.';
4908
+ }
4909
+ } catch (error) {
4910
+ const note = panel.querySelector('#storage-note');
4911
+ if (note) note.textContent = error.message;
4912
+ button.disabled = false;
4913
+ }
4914
+ };
4915
+ }
4916
+ };
4917
+
4918
+ await draw();
4919
+ }
4920
+
4826
4921
  async function renderTokenEditor(into) {
4827
4922
  const panel = document.createElement('div');
4828
4923
  panel.className = 'panel';
@@ -5044,6 +5139,7 @@ Every piece is hashed against the ` +
5044
5139
 
5045
5140
  const SETTINGS_GROUPS = [
5046
5141
  'Serving tiles',
5142
+ 'Storage',
5047
5143
  'Feeds',
5048
5144
  'Publishing',
5049
5145
  'Network',
@@ -6323,6 +6419,7 @@ Every piece is hashed against the ` +
6323
6419
  // Tokens are credentials, so they sit with the rest of who may reach
6324
6420
  // this node. Hooks run a command when a download finishes, which is
6325
6421
  // what the Transfers tab is about.
6422
+ await renderStorageEditor(paneFor('Storage'));
6326
6423
  await renderTokenEditor(paneFor('Security'));
6327
6424
  renderHookEditor(paneFor('Transfers'), config, restartKeys, true);
6328
6425
 
@@ -7246,6 +7343,8 @@ Every piece is hashed against the ` +
7246
7343
  : `<button type="button" data-stack-bake="${escapeHtml(stack.id)}" title="Run this stack over its sources and write the result as a real archive, with its own infohash, seeded like any other.">Export to archive</button>`
7247
7344
  }
7248
7345
  <button type="button" data-stack-edit="${escapeHtml(stack.id)}">Edit</button>
7346
+ <button type="button" data-stack-copy="${escapeHtml(stack.id)}"
7347
+ title="Opens the editor on a copy of this recipe, under a name of its own. Nothing is saved until you save it.">Duplicate</button>
7249
7348
  <button type="button" data-stack-delete="${escapeHtml(stack.id)}">Delete</button>`;
7250
7349
 
7251
7350
  return `
@@ -7335,6 +7434,26 @@ Every piece is hashed against the ` +
7335
7434
  // space, so a form that re-derived itself from its own inputs would have
7336
7435
  // to know which of them are currently real.
7337
7436
  let stackDraft = null;
7437
+ // Whether the dialog is naming a stack rather than editing one that is
7438
+ // already named, and what names are already spoken for. Held here
7439
+ // because the save is the only place it can be checked: `PUT` upserts,
7440
+ // so a name that is taken replaces what is there rather than refusing.
7441
+ let stackNaming = false;
7442
+ let stackNamesTaken = new Set();
7443
+
7444
+ /**
7445
+ * A name like this one that nothing is using.
7446
+ * @param {string} base - The name being copied.
7447
+ * @param {Set<string>} taken - What is spoken for.
7448
+ * @returns {string} - A free name.
7449
+ */
7450
+ const freeStackId = (base, taken) => {
7451
+ let candidate = `${base}-copy`;
7452
+ for (let n = 2; taken.has(candidate); n += 1) {
7453
+ candidate = `${base}-copy-${n}`;
7454
+ }
7455
+ return candidate;
7456
+ };
7338
7457
 
7339
7458
  /** Forty characters of hex is not a label. */
7340
7459
  const shortHash = (hash) => (hash ? `${hash.slice(0, 12)}…` : hash);
@@ -7343,24 +7462,35 @@ Every piece is hashed against the ` +
7343
7462
  const draftIsRgba = () => stackDraft?.space === 'rgba';
7344
7463
 
7345
7464
  /**
7346
- * Opens the dialog on a new stack, or on one that already exists.
7347
- * @param {object} [existing] - The stack to edit.
7465
+ * Opens the dialog on a new stack, on one that already exists, or on a
7466
+ * copy of one.
7467
+ * @param {object} [existing] - The stack to edit, or the copy to name.
7468
+ * @param {object} [options] - `copy` when the draft is a duplicate, and
7469
+ * `taken` the names already in use.
7348
7470
  * @returns {Promise<void>} - Resolves once the dialog is open.
7349
7471
  */
7350
- const openStackDialog = async (existing) => {
7472
+ const openStackDialog = async (existing, options = {}) => {
7351
7473
  stackDraft = existing
7352
7474
  ? JSON.parse(JSON.stringify(existing))
7353
7475
  : { id: '', title: '', space: 'elevation', sources: [], output: {} };
7354
7476
  stackDraft.sources ??= [];
7355
7477
  stackDraft.output ??= {};
7356
7478
 
7357
- $('stack-dialog-title').textContent = existing
7358
- ? 'Edit stack'
7359
- : 'Add stack';
7479
+ // A copy arrives filled in but unnamed as far as the node is
7480
+ // concerned, so it is edited like a new stack rather than like the one
7481
+ // it came from.
7482
+ stackNaming = Boolean(options.copy) || !existing;
7483
+ stackNamesTaken = new Set(options.taken ?? []);
7484
+
7485
+ $('stack-dialog-title').textContent = options.copy
7486
+ ? 'Duplicate stack'
7487
+ : existing
7488
+ ? 'Edit stack'
7489
+ : 'Add stack';
7360
7490
  // An id is what the URL is made of, so changing one would silently
7361
7491
  // orphan whatever points at the old address.
7362
7492
  $('stack-id').value = stackDraft.id ?? '';
7363
- $('stack-id').disabled = Boolean(existing);
7493
+ $('stack-id').disabled = !stackNaming;
7364
7494
  $('stack-title').value = stackDraft.title ?? '';
7365
7495
  for (const radio of document.querySelectorAll(
7366
7496
  'input[name="stack-space"]',
@@ -7737,9 +7867,11 @@ Every piece is hashed against the ` +
7737
7867
  }>px</option>
7738
7868
  </select>
7739
7869
  <span class="sub">${
7740
- featherWidth && !fadesAt
7741
- ? 'nothing to fade at yet: mask something, or clip it'
7742
- : ''
7870
+ featherWidth && index === 0 && !rgba
7871
+ ? 'the bottom source has nothing underneath to fade into'
7872
+ : featherWidth && !fadesAt
7873
+ ? 'nothing to fade at yet: mask something, or clip it'
7874
+ : ''
7743
7875
  }</span>
7744
7876
  </label>
7745
7877
  ${
@@ -7923,6 +8055,30 @@ Every piece is hashed against the ` +
7923
8055
  $('stack-form').addEventListener('submit', async (event) => {
7924
8056
  event.preventDefault();
7925
8057
  const id = $('stack-id').value.trim();
8058
+ // Caught here because the node cannot catch it: a stack with no name
8059
+ // is a `PUT /api/stacks/`, which matches no route at all, and what
8060
+ // comes back says only that the reply was not JSON.
8061
+ if (!id) {
8062
+ $('stack-error').textContent =
8063
+ 'A name is needed. It is what the stack is served under, so ' +
8064
+ 'there is nowhere to save one without it.';
8065
+ return;
8066
+ }
8067
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) {
8068
+ $('stack-error').textContent =
8069
+ 'A name may hold letters, digits, dots, dashes and underscores, ' +
8070
+ 'and has to start with a letter or a digit — it goes in a URL.';
8071
+ return;
8072
+ }
8073
+ // `PUT` upserts, so a name already in use is replaced rather than
8074
+ // refused -- which for a duplicate means quietly overwriting the very
8075
+ // stack it was copied from.
8076
+ if (stackNaming && stackNamesTaken.has(id)) {
8077
+ $('stack-error').textContent =
8078
+ `There is already a stack called "${id}". A name is what its URL ` +
8079
+ 'is made of, so saving would replace it rather than add this one.';
8080
+ return;
8081
+ }
7926
8082
  const body = {
7927
8083
  title: $('stack-title').value.trim() || undefined,
7928
8084
  space: stackDraft.space,
@@ -7988,8 +8144,16 @@ Every piece is hashed against the ` +
7988
8144
  loadStacks().catch((e) => toast(e.message));
7989
8145
  });
7990
8146
 
7991
- $('stacks-add').onclick = () =>
7992
- openStackDialog().catch((e) => toast(e.message));
8147
+ $('stacks-add').onclick = async () => {
8148
+ try {
8149
+ const { stacks } = await api('/api/stacks');
8150
+ await openStackDialog(undefined, {
8151
+ taken: stacks.map((one) => one.id),
8152
+ });
8153
+ } catch (error) {
8154
+ toast(error.message);
8155
+ }
8156
+ };
7993
8157
 
7994
8158
  /**
7995
8159
  * Asks where an export should land before starting it.
@@ -8133,6 +8297,7 @@ Every piece is hashed against the ` +
8133
8297
  // change, so they are delegated the same way the source rows are.
8134
8298
  $('stacks-list').addEventListener('click', async (event) => {
8135
8299
  const edit = event.target.dataset.stackEdit;
8300
+ const copy = event.target.dataset.stackCopy;
8136
8301
  const drop = event.target.dataset.stackDelete;
8137
8302
  const bake = event.target.dataset.stackBake;
8138
8303
  const stopBake = event.target.dataset.stackBakeStop;
@@ -8162,6 +8327,23 @@ Every piece is hashed against the ` +
8162
8327
  const raw = await api(`/api/stacks/${encodeURIComponent(edit)}/raw`);
8163
8328
  await openStackDialog(raw.stack);
8164
8329
  }
8330
+ } else if (copy !== undefined) {
8331
+ const { stacks } = await api('/api/stacks');
8332
+ // The recipe, not the report -- the same reason editing reads it.
8333
+ // A report holds what each source resolved to, so a copy made from
8334
+ // one would pin infohashes the original follows by category.
8335
+ const raw = await api(`/api/stacks/${encodeURIComponent(copy)}/raw`);
8336
+ const taken = stacks.map((one) => one.id);
8337
+ await openStackDialog(
8338
+ {
8339
+ ...raw.stack,
8340
+ id: freeStackId(copy, new Set(taken)),
8341
+ // Named apart on purpose: two stacks under one title are two
8342
+ // rows nobody can tell apart in the list they both appear in.
8343
+ title: `${raw.stack.title ?? copy} copy`,
8344
+ },
8345
+ { copy: true, taken },
8346
+ );
8165
8347
  } else if (drop !== undefined) {
8166
8348
  if (!confirm(`Delete the stack "${drop}"?`)) return;
8167
8349
  try {