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 +61 -0
- package/README.md +2 -0
- package/docs/internals.md +58 -0
- package/docs/tile-stacks.md +32 -1
- package/package.json +1 -1
- package/src/api.js +46 -0
- package/src/bake-jobs.js +28 -0
- package/src/stack-cache.js +19 -0
- package/src/storage.js +283 -0
- package/src/traffic-stats.js +12 -0
- package/src/web/index.html +195 -13
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.
|
package/docs/tile-stacks.md
CHANGED
|
@@ -1299,9 +1299,17 @@ mostly diagonals.
|
|
|
1299
1299
|
works out the pixels for each tile it builds:
|
|
1300
1300
|
|
|
1301
1301
|
```json
|
|
1302
|
-
{ "
|
|
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.
|
|
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
|
*
|
package/src/stack-cache.js
CHANGED
|
@@ -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
|
+
}
|
package/src/traffic-stats.js
CHANGED
|
@@ -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.
|
package/src/web/index.html
CHANGED
|
@@ -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,
|
|
7347
|
-
*
|
|
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
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
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 =
|
|
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 && !
|
|
7741
|
-
? '
|
|
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
|
-
|
|
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 {
|