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 +31 -0
- package/README.md +2 -0
- package/docs/internals.md +58 -0
- package/docs/tile-stacks.md +9 -1
- package/package.json +1 -1
- package/src/api.js +33 -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 +101 -3
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.
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "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
|
*
|
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
|
@@ -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 && !
|
|
7741
|
-
? '
|
|
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
|
${
|