pmtiles-swarm 0.41.2 → 0.43.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 +52 -0
- package/docs/configuration.md +14 -0
- package/docs/publishing.md +20 -1
- package/package.json +1 -1
- package/src/api.js +57 -0
- package/src/config.js +10 -0
- package/src/library.js +77 -0
- package/src/web/index.html +266 -79
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.43.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **Six detail tabs are now five, and the two bars that matter lead the General tab.** How much of an
|
|
13
|
+
archive is here, and whether it can still be completed, are the first things anybody opening one
|
|
14
|
+
wants — and they were a tab away, above an explanation of how the drawing works. They now sit above
|
|
15
|
+
the infohash row, where the question is asked.
|
|
16
|
+
|
|
17
|
+
What that left on the Pieces tab was per-peer data, which is what the Peers tab already was. The two
|
|
18
|
+
are one: each peer's row shows what it holds beside how it is connected and how fast, joined from
|
|
19
|
+
the two endpoints that each knew half of it. A peer either side knows about still gets a row, since
|
|
20
|
+
half an answer about a peer beats leaving it out.
|
|
21
|
+
|
|
22
|
+
The General bars are redrawn into their canvases rather than by re-rendering the pane, so the
|
|
23
|
+
category box below them keeps whatever was being typed across the three-second refresh.
|
|
24
|
+
|
|
25
|
+
### 🐞 Bug fixes
|
|
26
|
+
|
|
27
|
+
## 0.42.0
|
|
28
|
+
### ✨ Features and improvements
|
|
29
|
+
- **Recheck all, Pause all and Resume all**, in the archives toolbar. Each does to every archive what
|
|
30
|
+
the per-archive action does to one — sequentially, because rechecking is disk-bound and firing
|
|
31
|
+
twenty at once at a library of planet builds is a way to make a node unresponsive while it works.
|
|
32
|
+
|
|
33
|
+
Each skips what does not need doing and reports how many were done, skipped and failed; a failure
|
|
34
|
+
is counted and named rather than thrown, since these run over archives nobody has looked at
|
|
35
|
+
individually and stopping at the first would leave the rest in an unknown state. **Recheck all** is
|
|
36
|
+
the one worth knowing about after a disk repair: every other answer about how much of an archive is
|
|
37
|
+
here comes from something written down earlier, and this is the only thing that goes and looks.
|
|
38
|
+
`POST /api/library/recheck`, `/api/library/pause` and `/api/library/resume` are the same three from
|
|
39
|
+
a script.
|
|
40
|
+
- **A node can be taken out of rotation without being stopped.** `offline` — or the **Take offline**
|
|
41
|
+
switch in the console header, beside the engine and the reachability dot, where the things that
|
|
42
|
+
describe the node rather than an archive live — makes `/health` answer `503` with `status: "offline"`, which is what a load balancer
|
|
43
|
+
reads to stop sending traffic here. It is answered before the engine is asked, so a node stays
|
|
44
|
+
drained whatever the engine happens to think.
|
|
45
|
+
|
|
46
|
+
Nothing else changes: seeding, the console and the library carry on, because draining traffic and
|
|
47
|
+
stopping work are separate decisions and one switch doing both would mean a node could not be
|
|
48
|
+
drained without also being idled. It is stored in the configuration rather than in memory, so it
|
|
49
|
+
survives the restart you were probably about to do.
|
|
50
|
+
|
|
51
|
+
### 🐞 Bug fixes
|
|
52
|
+
|
|
53
|
+
## 0.41.3
|
|
54
|
+
### ✨ Features and improvements
|
|
55
|
+
- **The details panel no longer repeats the archive's name.** It opens directly under the row that
|
|
56
|
+
names it, so the heading was the same words twice, one line apart — it was there because the panel
|
|
57
|
+
used to render below the whole table, where nothing said which archive it belonged to. The panel is
|
|
58
|
+
labelled for assistive technology instead, which has no "just above" to read from.
|
|
59
|
+
|
|
60
|
+
### 🐞 Bug fixes
|
|
61
|
+
|
|
10
62
|
## 0.41.2
|
|
11
63
|
### ✨ Features and improvements
|
|
12
64
|
|
package/docs/configuration.md
CHANGED
|
@@ -298,6 +298,20 @@ Nothing computes an MD5 for a [subscription](#subscriptions): those adopt a
|
|
|
298
298
|
torrent somebody else built, so there is no hashing pass here to extend. What
|
|
299
299
|
their feed publishes as `<pmtiles:md5>` is recorded as it arrives.
|
|
300
300
|
|
|
301
|
+
### `offline`
|
|
302
|
+
|
|
303
|
+
Off by default. Set it — or press **Take offline** in the console — and `/health`
|
|
304
|
+
answers `503` with `status: "offline"`, which is what a load balancer reads to
|
|
305
|
+
stop sending traffic here.
|
|
306
|
+
|
|
307
|
+
Nothing else changes. The node keeps seeding, keeps answering the console, and
|
|
308
|
+
keeps its library: draining traffic and stopping work are separate decisions, and
|
|
309
|
+
one switch doing both would mean a node could not be drained without also being
|
|
310
|
+
idled. Use **Pause all** for the other half.
|
|
311
|
+
|
|
312
|
+
It lives in the configuration rather than in memory, so a node taken out of
|
|
313
|
+
rotation stays out across the restart you were probably about to do.
|
|
314
|
+
|
|
301
315
|
## Trackers
|
|
302
316
|
|
|
303
317
|
`trackers` is baked into every torrent this node creates. It defaults to the
|
package/docs/publishing.md
CHANGED
|
@@ -79,7 +79,26 @@ A magnet, a `.torrent` URL and an uploaded `.torrent` are metadata rather than
|
|
|
79
79
|
data, so there is nothing slow to wait for: those still answer `201` with the
|
|
80
80
|
entry.
|
|
81
81
|
|
|
82
|
-
###
|
|
82
|
+
### Acting on the whole library
|
|
83
|
+
|
|
84
|
+
Three buttons in the archives toolbar do to every archive what the per-archive
|
|
85
|
+
actions do to one, in sequence rather than all at once:
|
|
86
|
+
|
|
87
|
+
| button | what it does |
|
|
88
|
+
| --------------- | --------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| **Recheck all** | hashes every archive against its torrent. What you want after a disk repair — nothing else goes and looks |
|
|
90
|
+
| **Pause all** | stops offering everything. The data and the catalog stay |
|
|
91
|
+
| **Resume all** | starts everything that is paused |
|
|
92
|
+
|
|
93
|
+
Each skips archives that do not need it and reports how many were done, skipped
|
|
94
|
+
and failed. One failure does not stop the rest: these run over archives nobody
|
|
95
|
+
has looked at individually, and stopping at the first would leave the others in
|
|
96
|
+
an unknown state.
|
|
97
|
+
|
|
98
|
+
`POST /api/library/recheck`, `/api/library/pause` and `/api/library/resume` are
|
|
99
|
+
the same three from a script.
|
|
100
|
+
|
|
101
|
+
## Stopping one that is running
|
|
83
102
|
|
|
84
103
|
`DELETE /api/adds?url=…` stops an add before it has produced a torrent, and the
|
|
85
104
|
console offers it as a Cancel button beside each one. The identifier is whatever
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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
|
@@ -573,6 +573,22 @@ export function createApp({
|
|
|
573
573
|
app.get(
|
|
574
574
|
'/health',
|
|
575
575
|
route(async (_req, res) => {
|
|
576
|
+
// Answered before the engine is asked, and answered the same way every
|
|
577
|
+
// time. An operator who has taken a node out of rotation wants it out
|
|
578
|
+
// whatever the engine happens to say, and wants that to survive the
|
|
579
|
+
// restart they are probably about to do — which is why it lives in the
|
|
580
|
+
// configuration rather than in memory.
|
|
581
|
+
if (config.offline) {
|
|
582
|
+
res.setHeader('cache-control', 'no-store');
|
|
583
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
584
|
+
return res.status(503).json({
|
|
585
|
+
status: 'offline',
|
|
586
|
+
engine: engine.name,
|
|
587
|
+
version: VERSION,
|
|
588
|
+
error: 'this node has been taken out of rotation by an operator',
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
576
592
|
const now = Date.now();
|
|
577
593
|
if (now - healthChecked >= HEALTH_TTL_MS) {
|
|
578
594
|
healthChecked = now;
|
|
@@ -612,6 +628,7 @@ export function createApp({
|
|
|
612
628
|
}
|
|
613
629
|
res.json({
|
|
614
630
|
version: VERSION,
|
|
631
|
+
offline: Boolean(config.offline),
|
|
615
632
|
// What an unfinished archive is actually called on disk, or null when
|
|
616
633
|
// nothing renames it. Both halves matter: the setting can be empty,
|
|
617
634
|
// and an engine can ignore it entirely. The console showed the marker
|
|
@@ -1103,6 +1120,46 @@ export function createApp({
|
|
|
1103
1120
|
}),
|
|
1104
1121
|
);
|
|
1105
1122
|
|
|
1123
|
+
// The whole library at once. Each of these is a shell loop over the
|
|
1124
|
+
// per-archive route otherwise, and the moment somebody wants one -- a disk
|
|
1125
|
+
// that has just been repaired, a node about to be taken down -- is exactly
|
|
1126
|
+
// when they are least inclined to write one.
|
|
1127
|
+
//
|
|
1128
|
+
// Under /api/library rather than /api/torrents, so no path here can ever be
|
|
1129
|
+
// mistaken for an infohash.
|
|
1130
|
+
app.post(
|
|
1131
|
+
'/api/library/recheck',
|
|
1132
|
+
route(async (_req, res) => {
|
|
1133
|
+
try {
|
|
1134
|
+
res.status(202).json(await library.recheckAll());
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
res.status(error.status ?? 500).json({ error: error.message });
|
|
1137
|
+
}
|
|
1138
|
+
}),
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
app.post(
|
|
1142
|
+
'/api/library/pause',
|
|
1143
|
+
route(async (_req, res) => {
|
|
1144
|
+
try {
|
|
1145
|
+
res.json(await library.pauseAll());
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
res.status(error.status ?? 500).json({ error: error.message });
|
|
1148
|
+
}
|
|
1149
|
+
}),
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
app.post(
|
|
1153
|
+
'/api/library/resume',
|
|
1154
|
+
route(async (_req, res) => {
|
|
1155
|
+
try {
|
|
1156
|
+
res.json(await library.resumeAll());
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
res.status(error.status ?? 500).json({ error: error.message });
|
|
1159
|
+
}
|
|
1160
|
+
}),
|
|
1161
|
+
);
|
|
1162
|
+
|
|
1106
1163
|
// Joining defaults to cache, deliberately. This is how that is changed
|
|
1107
1164
|
// afterwards, without re-adding the archive by hand.
|
|
1108
1165
|
app.patch(
|
package/src/config.js
CHANGED
|
@@ -411,6 +411,16 @@ const DEFAULTS = {
|
|
|
411
411
|
* power to a token has to be made somewhere a token cannot reach.
|
|
412
412
|
*/
|
|
413
413
|
allowHooksFromApi: false,
|
|
414
|
+
/**
|
|
415
|
+
* Take this node out of rotation without stopping it.
|
|
416
|
+
*
|
|
417
|
+
* `/health` answers 503 while this is set, which is what a load balancer
|
|
418
|
+
* reads to stop sending traffic here. Nothing else changes: the node keeps
|
|
419
|
+
* seeding, keeps answering the console, and keeps its library — draining
|
|
420
|
+
* traffic and stopping work are separate decisions, and doing both from one
|
|
421
|
+
* switch would mean a node could not be drained without also being idled.
|
|
422
|
+
*/
|
|
423
|
+
offline: false,
|
|
414
424
|
/** How often to look for finished downloads, in seconds. */
|
|
415
425
|
onCompleteCheckIntervalSeconds: 60,
|
|
416
426
|
/**
|
package/src/library.js
CHANGED
|
@@ -1819,6 +1819,83 @@ export class Library {
|
|
|
1819
1819
|
return queued;
|
|
1820
1820
|
}
|
|
1821
1821
|
|
|
1822
|
+
/**
|
|
1823
|
+
* Does something to the whole library, one archive at a time.
|
|
1824
|
+
*
|
|
1825
|
+
* One at a time on purpose. Every one of these is a request to the engine
|
|
1826
|
+
* and rechecking is disk-bound, so firing twenty at once on a library of
|
|
1827
|
+
* planet builds is a way to make a node unresponsive while it works. Nothing
|
|
1828
|
+
* here is urgent enough to be worth that.
|
|
1829
|
+
*
|
|
1830
|
+
* A failure is counted rather than thrown: these run over archives the
|
|
1831
|
+
* caller has not looked at individually, and stopping at the first would
|
|
1832
|
+
* leave the rest in an unknown state with no way to tell which.
|
|
1833
|
+
* @param {Function} pick - Given an entry, whether it needs doing.
|
|
1834
|
+
* @param {Function} act - Given an infohash, does it.
|
|
1835
|
+
* @returns {Promise<{done: string[], skipped: number, failed: object[]}>} - What happened.
|
|
1836
|
+
*/
|
|
1837
|
+
async #eachArchive(pick, act) {
|
|
1838
|
+
const done = [];
|
|
1839
|
+
const failed = [];
|
|
1840
|
+
let skipped = 0;
|
|
1841
|
+
|
|
1842
|
+
for (const entry of this.#catalog.list()) {
|
|
1843
|
+
if (!pick(entry)) {
|
|
1844
|
+
skipped += 1;
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
try {
|
|
1848
|
+
await act(entry.infoHash);
|
|
1849
|
+
done.push(entry.infoHash);
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
failed.push({ name: entry.name, error: error.message });
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
return { done, skipped, failed };
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Hashes every archive against its torrent.
|
|
1860
|
+
*
|
|
1861
|
+
* The operation somebody wants after a disk repair, and the only one that
|
|
1862
|
+
* goes and looks: every other answer about how much of an archive is here
|
|
1863
|
+
* comes from something written down earlier. Doing it one archive at a time
|
|
1864
|
+
* from a shell loop is the alternative, which is exactly when nobody is
|
|
1865
|
+
* inclined to write one.
|
|
1866
|
+
* @returns {Promise<{done: string[], skipped: number, failed: object[]}>} - What happened.
|
|
1867
|
+
*/
|
|
1868
|
+
async recheckAll() {
|
|
1869
|
+
// A paused archive cannot be rechecked by an engine that rechecks by
|
|
1870
|
+
// re-adding, and skipping it beats failing the whole sweep on it.
|
|
1871
|
+
return this.#eachArchive(
|
|
1872
|
+
(entry) => !entry.paused,
|
|
1873
|
+
(infoHash) => this.recheck(infoHash),
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Stops offering every archive, without forgetting any.
|
|
1879
|
+
* @returns {Promise<{done: string[], skipped: number, failed: object[]}>} - What happened.
|
|
1880
|
+
*/
|
|
1881
|
+
async pauseAll() {
|
|
1882
|
+
return this.#eachArchive(
|
|
1883
|
+
(entry) => !entry.paused,
|
|
1884
|
+
(infoHash) => this.pause(infoHash),
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Starts every archive that was paused.
|
|
1890
|
+
* @returns {Promise<{done: string[], skipped: number, failed: object[]}>} - What happened.
|
|
1891
|
+
*/
|
|
1892
|
+
async resumeAll() {
|
|
1893
|
+
return this.#eachArchive(
|
|
1894
|
+
(entry) => entry.paused,
|
|
1895
|
+
(infoHash) => this.resume(infoHash),
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1822
1899
|
/**
|
|
1823
1900
|
* Reconciles what the catalog says about an archive with what is on disk.
|
|
1824
1901
|
*
|
package/src/web/index.html
CHANGED
|
@@ -224,11 +224,13 @@
|
|
|
224
224
|
}
|
|
225
225
|
.tag.on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
226
226
|
#add-error { color: var(--bad); min-height: 1.2rem; }
|
|
227
|
+
/* Separates what acts on one archive from what acts on all of them. */
|
|
228
|
+
.bar-gap { flex: 1 1 1.5rem; min-width: 0.75rem; }
|
|
227
229
|
.tabs {
|
|
228
230
|
display: flex;
|
|
229
231
|
gap: 0.25rem;
|
|
230
232
|
border-bottom: 1px solid var(--line);
|
|
231
|
-
margin:
|
|
233
|
+
margin: 0 0 1rem;
|
|
232
234
|
flex-wrap: wrap;
|
|
233
235
|
}
|
|
234
236
|
.tabs button {
|
|
@@ -339,8 +341,10 @@
|
|
|
339
341
|
.addrow > .value { font-size: 0.8rem; color: var(--muted); text-align: right; white-space: nowrap; }
|
|
340
342
|
.addrow > button { padding: 0.15rem 0.55rem; font-size: 0.75rem; }
|
|
341
343
|
|
|
342
|
-
button.speed
|
|
343
|
-
button.
|
|
344
|
+
button.speed,
|
|
345
|
+
button.state { border-color: var(--line); color: var(--muted); }
|
|
346
|
+
button.speed.on,
|
|
347
|
+
button.state.on { border-color: var(--warn); color: var(--warn); }
|
|
344
348
|
</style>
|
|
345
349
|
</head>
|
|
346
350
|
<body>
|
|
@@ -349,6 +353,7 @@
|
|
|
349
353
|
<div class="status" id="status">connecting…</div>
|
|
350
354
|
<span id="reach" class="reach" hidden></span>
|
|
351
355
|
<button id="speed-toggle" class="speed" hidden title="Switch between the normal and alternative speed limits"></button>
|
|
356
|
+
<button id="offline-toggle" class="state" hidden title="Take this node out of rotation. /health answers 503 while it is off, which is what a load balancer reads to stop sending traffic here — seeding, the console and the library carry on."></button>
|
|
352
357
|
<nav>
|
|
353
358
|
<button id="tab-archives" class="on">Archives</button>
|
|
354
359
|
<button id="tab-categories">Categories</button>
|
|
@@ -366,6 +371,10 @@
|
|
|
366
371
|
<a id="feed-link" href="/feed.xml" target="_blank" rel="noreferrer">
|
|
367
372
|
<button type="button">Feed</button>
|
|
368
373
|
</a>
|
|
374
|
+
<span class="bar-gap"></span>
|
|
375
|
+
<button id="recheck-all" title="Hash every archive against its torrent and believe the result. What you want after a disk repair — nothing else goes and looks. Progress reads as the fraction hashed while it runs.">Recheck all</button>
|
|
376
|
+
<button id="pause-all" title="Stop offering every archive. The data and the catalog stay; this is "not right now", not "not any more".">Pause all</button>
|
|
377
|
+
<button id="resume-all" title="Start every archive that is paused.">Resume all</button>
|
|
369
378
|
</div>
|
|
370
379
|
<div class="bar">
|
|
371
380
|
<input
|
|
@@ -954,7 +963,7 @@
|
|
|
954
963
|
* four requests a tick for nothing, which is why they are loaded lazily
|
|
955
964
|
* in the first place.
|
|
956
965
|
*/
|
|
957
|
-
const LIVE_PANES = new Set(['
|
|
966
|
+
const LIVE_PANES = new Set(['peers']);
|
|
958
967
|
let refreshingPane = false;
|
|
959
968
|
|
|
960
969
|
// What the header switch shows, so a click knows what it is toggling to.
|
|
@@ -1188,6 +1197,7 @@
|
|
|
1188
1197
|
// What an unfinished archive is really called on disk, or null when
|
|
1189
1198
|
// nothing renames it. The engine decides, not the setting alone.
|
|
1190
1199
|
incompleteMarker = status.incompleteMarker ?? null;
|
|
1200
|
+
renderOffline(Boolean(status.offline));
|
|
1191
1201
|
const engine = status.engine;
|
|
1192
1202
|
renderReach(status.reachability);
|
|
1193
1203
|
$('status').innerHTML =
|
|
@@ -1263,6 +1273,126 @@
|
|
|
1263
1273
|
return `${minutes}m`;
|
|
1264
1274
|
}
|
|
1265
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* Runs one of the whole-library actions and reports what it did.
|
|
1278
|
+
*
|
|
1279
|
+
* Disabled while it runs, because each of these walks the library one
|
|
1280
|
+
* archive at a time and a second click would start a second walk over
|
|
1281
|
+
* the same archives.
|
|
1282
|
+
* @param {string} id - The button's id.
|
|
1283
|
+
* @param {string} path - The endpoint to post to.
|
|
1284
|
+
* @param {Function} confirm - Given the archive count, the question to ask, or null to just do it.
|
|
1285
|
+
* @param {Function} describe - Given the result, what to say afterwards.
|
|
1286
|
+
* @returns {void}
|
|
1287
|
+
*/
|
|
1288
|
+
function bulkAction(id, path, confirm, describe) {
|
|
1289
|
+
$(id).onclick = async () => {
|
|
1290
|
+
const question = confirm?.(archives.length);
|
|
1291
|
+
if (question && !window.confirm(question)) return;
|
|
1292
|
+
|
|
1293
|
+
const button = $(id);
|
|
1294
|
+
const label = button.textContent;
|
|
1295
|
+
button.disabled = true;
|
|
1296
|
+
button.textContent = 'working…';
|
|
1297
|
+
try {
|
|
1298
|
+
const result = await api(path, { method: 'POST' });
|
|
1299
|
+
toast(describe(result));
|
|
1300
|
+
refresh();
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
toast(error.message);
|
|
1303
|
+
} finally {
|
|
1304
|
+
button.disabled = false;
|
|
1305
|
+
button.textContent = label;
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* How a whole-library action turned out, in one line.
|
|
1312
|
+
* @param {object} result - `{done, skipped, failed}` from the server.
|
|
1313
|
+
* @param {string} verb - What was done to the ones that were.
|
|
1314
|
+
* @returns {string} - The summary.
|
|
1315
|
+
*/
|
|
1316
|
+
function bulkSummary(result, verb) {
|
|
1317
|
+
const parts = [`${result.done.length} ${verb}`];
|
|
1318
|
+
if (result.skipped) parts.push(`${result.skipped} already were`);
|
|
1319
|
+
if (result.failed.length) {
|
|
1320
|
+
parts.push(`${result.failed.length} failed`);
|
|
1321
|
+
// Named in the log as well: a toast is gone in a few seconds and the
|
|
1322
|
+
// one thing somebody needs afterwards is which archive it was.
|
|
1323
|
+
for (const failure of result.failed) {
|
|
1324
|
+
console.error(`${failure.name}: ${failure.error}`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return parts.join(', ');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
bulkAction(
|
|
1331
|
+
'recheck-all',
|
|
1332
|
+
'/api/library/recheck',
|
|
1333
|
+
(count) =>
|
|
1334
|
+
`Hash all ${count} archives against their torrents.\n\n` +
|
|
1335
|
+
'This reads every byte on disk, so it takes a while on a large ' +
|
|
1336
|
+
'library, and each archive reads as low progress until its own ' +
|
|
1337
|
+
'check finishes. Nothing is deleted.',
|
|
1338
|
+
(result) => bulkSummary(result, 'rechecking'),
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
bulkAction(
|
|
1342
|
+
'pause-all',
|
|
1343
|
+
'/api/library/pause',
|
|
1344
|
+
(count) =>
|
|
1345
|
+
`Stop offering all ${count} archives.\n\n` +
|
|
1346
|
+
'The data and the catalog stay. Nothing is served to the swarm ' +
|
|
1347
|
+
'until they are resumed.',
|
|
1348
|
+
(result) => bulkSummary(result, 'paused'),
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
bulkAction('resume-all', '/api/library/resume', null, (result) =>
|
|
1352
|
+
bulkSummary(result, 'resumed'),
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Puts the offline switch in the state the node is actually in.
|
|
1357
|
+
*
|
|
1358
|
+
* Read from the node rather than remembered here, so a second console —
|
|
1359
|
+
* or the same one after a reload — shows the truth rather than whatever
|
|
1360
|
+
* this tab last did.
|
|
1361
|
+
* @param {boolean} offline - Whether the node is out of rotation.
|
|
1362
|
+
* @returns {void}
|
|
1363
|
+
*/
|
|
1364
|
+
function renderOffline(offline) {
|
|
1365
|
+
const button = $('offline-toggle');
|
|
1366
|
+
button.hidden = false;
|
|
1367
|
+
button.classList.toggle('on', offline);
|
|
1368
|
+
button.textContent = offline ? 'Bring online' : 'Take offline';
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
$('offline-toggle').onclick = async () => {
|
|
1372
|
+
const going = !$('offline-toggle').classList.contains('on');
|
|
1373
|
+
if (
|
|
1374
|
+
going &&
|
|
1375
|
+
!window.confirm(
|
|
1376
|
+
'Take this node out of rotation.\n\n' +
|
|
1377
|
+
'/health will answer 503, so a load balancer stops sending it ' +
|
|
1378
|
+
'traffic. Seeding, the console and the library are unaffected, ' +
|
|
1379
|
+
'and this survives a restart.',
|
|
1380
|
+
)
|
|
1381
|
+
) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
await api('/api/config', {
|
|
1386
|
+
method: 'PATCH',
|
|
1387
|
+
body: { offline: going },
|
|
1388
|
+
});
|
|
1389
|
+
renderOffline(going);
|
|
1390
|
+
toast(going ? 'taken out of rotation' : 'back in rotation');
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
toast(error.message);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1266
1396
|
/**
|
|
1267
1397
|
* Closes the details, panel and row together.
|
|
1268
1398
|
*
|
|
@@ -1436,6 +1566,7 @@
|
|
|
1436
1566
|
* @returns {void}
|
|
1437
1567
|
*/
|
|
1438
1568
|
function refreshOpenPane() {
|
|
1569
|
+
if (activeTab === 'general' && selected) renderPieceBars(selected);
|
|
1439
1570
|
if (refreshingPane || !selected || !LIVE_PANES.has(activeTab)) return;
|
|
1440
1571
|
const pane = document.querySelector(`[data-pane="${activeTab}"]`);
|
|
1441
1572
|
// Only while it is on screen: a hidden pane is one the reader moved
|
|
@@ -1517,92 +1648,80 @@
|
|
|
1517
1648
|
}
|
|
1518
1649
|
|
|
1519
1650
|
if (name === 'peers') {
|
|
1520
|
-
const
|
|
1651
|
+
const width = Math.max(256, Math.min(2048, pane.clientWidth || 1000));
|
|
1652
|
+
// Both, because they answer halves of the same question and were
|
|
1653
|
+
// two tabs for no better reason than that they come from two
|
|
1654
|
+
// endpoints. `/peers` knows how a peer is connected and how fast;
|
|
1655
|
+
// `/pieces` knows what it holds. Neither is much use alone.
|
|
1656
|
+
const [answer, info] = await Promise.all([
|
|
1657
|
+
api(`/api/torrents/${infoHash}/peers`),
|
|
1658
|
+
api(`/api/torrents/${infoHash}/pieces?buckets=${width}&peers=true`).catch(
|
|
1659
|
+
() => ({}),
|
|
1660
|
+
),
|
|
1661
|
+
]);
|
|
1662
|
+
|
|
1521
1663
|
// Either a bare array, or `{ peers, error }` when an engine could
|
|
1522
1664
|
// not answer. "Nothing to show" is the right thing to say about an
|
|
1523
1665
|
// empty swarm and the wrong thing to say about a broken engine.
|
|
1524
1666
|
const peers = Array.isArray(answer) ? answer : (answer?.peers ?? []);
|
|
1525
1667
|
const failed = Array.isArray(answer) ? null : answer?.error;
|
|
1668
|
+
|
|
1669
|
+
// Joined on address, which is the only thing both sides agree on.
|
|
1670
|
+
// A peer in one and not the other still gets its row: half an
|
|
1671
|
+
// answer about a peer beats leaving it out of the list.
|
|
1672
|
+
const bitfields = new Map(
|
|
1673
|
+
(info.peers ?? []).map((peer) => [peer.address, peer.have]),
|
|
1674
|
+
);
|
|
1675
|
+
const listed = new Set(peers.map((peer) => peer.address ?? peer.ip));
|
|
1676
|
+
const extra = (info.peers ?? []).filter(
|
|
1677
|
+
(peer) => !listed.has(peer.address),
|
|
1678
|
+
);
|
|
1679
|
+
|
|
1680
|
+
const rows = [...peers, ...extra].map((peer, index) => {
|
|
1681
|
+
const address = peer.address ?? peer.ip ?? '—';
|
|
1682
|
+
const have = bitfields.get(address);
|
|
1683
|
+
return [
|
|
1684
|
+
escapeHtml(address),
|
|
1685
|
+
escapeHtml(peer.client ?? '—'),
|
|
1686
|
+
escapeHtml(peer.kind ?? 'peer'),
|
|
1687
|
+
escapeHtml(peer.connection ?? '—'),
|
|
1688
|
+
have
|
|
1689
|
+
? `<canvas class="piecebar thin" data-bar="peer-${index}"></canvas>`
|
|
1690
|
+
: '<span class="sub">not reported</span>',
|
|
1691
|
+
peer.progress != null ? pct(peer.progress) : '—',
|
|
1692
|
+
rate(peer.downloadSpeed ?? peer.dlSpeed),
|
|
1693
|
+
rate(peer.uploadSpeed ?? peer.upSpeed),
|
|
1694
|
+
];
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1526
1697
|
pane.innerHTML =
|
|
1527
1698
|
table(
|
|
1528
|
-
['Address', 'Client', 'Kind', 'Conn', 'Progress', 'Down', 'Up'],
|
|
1529
|
-
|
|
1530
|
-
escapeHtml(peer.address ?? peer.ip ?? '—'),
|
|
1531
|
-
escapeHtml(peer.client ?? '—'),
|
|
1532
|
-
escapeHtml(peer.kind ?? 'peer'),
|
|
1533
|
-
escapeHtml(peer.connection ?? '—'),
|
|
1534
|
-
peer.progress != null ? pct(peer.progress) : '—',
|
|
1535
|
-
rate(peer.downloadSpeed ?? peer.dlSpeed),
|
|
1536
|
-
rate(peer.uploadSpeed ?? peer.upSpeed),
|
|
1537
|
-
]),
|
|
1699
|
+
['Address', 'Client', 'Kind', 'Conn', 'Has', 'Progress', 'Down', 'Up'],
|
|
1700
|
+
rows,
|
|
1538
1701
|
) +
|
|
1539
1702
|
(failed
|
|
1540
1703
|
? `<div class="sub" style="margin-top:0.5rem">
|
|
1541
1704
|
The engine could not list peers: ${escapeHtml(failed)}
|
|
1542
1705
|
</div>`
|
|
1706
|
+
: '') +
|
|
1707
|
+
(rows.length > 0
|
|
1708
|
+
? `<div class="sub" style="margin-top:0.5rem">
|
|
1709
|
+
<b>Has</b> is drawn the same way as the bars on General —
|
|
1710
|
+
a column covers many pieces and is only filled when all of
|
|
1711
|
+
them are held. <b>Progress</b> is the engine's own figure
|
|
1712
|
+
for the same peer, which is exact.
|
|
1713
|
+
</div>`
|
|
1543
1714
|
: '');
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
1715
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
const held = info.haveCount ?? 0;
|
|
1558
|
-
const pctHeld = info.numPieces ? (held / info.numPieces) * 100 : 0;
|
|
1559
|
-
pane.innerHTML = `
|
|
1560
|
-
<div class="piecerow">
|
|
1561
|
-
<span class="label">Downloaded</span>
|
|
1562
|
-
<canvas class="piecebar" data-bar="have"></canvas>
|
|
1563
|
-
<span class="value">${pctHeld.toFixed(1)}%</span>
|
|
1564
|
-
</div>
|
|
1565
|
-
<div class="piecerow">
|
|
1566
|
-
<span class="label">Availability</span>
|
|
1567
|
-
<canvas class="piecebar" data-bar="availability"></canvas>
|
|
1568
|
-
<span class="value">${(info.distributedCopies ?? 0).toFixed(3)}</span>
|
|
1569
|
-
</div>
|
|
1570
|
-
<div class="sub" style="margin:0.6rem 0 1rem">
|
|
1571
|
-
${info.numPieces} pieces of ${bytes(info.pieceLength)}, drawn in
|
|
1572
|
-
${info.buckets} columns — so one column is
|
|
1573
|
-
${Math.ceil(info.numPieces / info.buckets)} piece(s), and is only
|
|
1574
|
-
shown as held when <i>all</i> of them are. Availability is the
|
|
1575
|
-
<i>rarest</i> piece in each column: one piece nobody has is the
|
|
1576
|
-
answer to "can this still be completed", however well supplied
|
|
1577
|
-
its neighbours are. It counts <i>connected peers</i> and not
|
|
1578
|
-
this node, so an archive only this node holds shows an empty
|
|
1579
|
-
bar — which is the truth about the swarm, not about the file.
|
|
1580
|
-
</div>
|
|
1581
|
-
${
|
|
1582
|
-
(info.peers ?? []).length > 0
|
|
1583
|
-
? `<h3 style="margin:0 0 0.5rem">What each peer has</h3>` +
|
|
1584
|
-
(info.peers ?? [])
|
|
1585
|
-
.map(
|
|
1586
|
-
(peer, index) => `
|
|
1587
|
-
<div class="piecerow">
|
|
1588
|
-
<span class="label" title="${escapeHtml(peer.client ?? '')}">${escapeHtml(peer.address ?? '?')}</span>
|
|
1589
|
-
<canvas class="piecebar thin" data-bar="peer-${index}"></canvas>
|
|
1590
|
-
<span class="value">${peer.progress != null ? pct(peer.progress) : ''}</span>
|
|
1591
|
-
</div>`,
|
|
1592
|
-
)
|
|
1593
|
-
.join('')
|
|
1594
|
-
: '<div class="sub">No connected peer is reporting what it holds.</div>'
|
|
1595
|
-
}`;
|
|
1596
|
-
|
|
1597
|
-
drawPieceBar(pane.querySelector('[data-bar="have"]'), info.have, 'have');
|
|
1598
|
-
drawPieceBar(
|
|
1599
|
-
pane.querySelector('[data-bar="availability"]'),
|
|
1600
|
-
info.availability,
|
|
1601
|
-
'availability',
|
|
1602
|
-
);
|
|
1603
|
-
(info.peers ?? []).forEach((peer, index) =>
|
|
1604
|
-
drawPieceBar(pane.querySelector(`[data-bar="peer-${index}"]`), peer.have, 'peer'),
|
|
1605
|
-
);
|
|
1716
|
+
[...peers, ...extra].forEach((peer, index) => {
|
|
1717
|
+
const have = bitfields.get(peer.address ?? peer.ip);
|
|
1718
|
+
if (!have) return;
|
|
1719
|
+
drawPieceBar(
|
|
1720
|
+
pane.querySelector(`[data-bar="peer-${index}"]`),
|
|
1721
|
+
have,
|
|
1722
|
+
'peer',
|
|
1723
|
+
);
|
|
1724
|
+
});
|
|
1606
1725
|
return;
|
|
1607
1726
|
}
|
|
1608
1727
|
|
|
@@ -1645,6 +1764,56 @@
|
|
|
1645
1764
|
}
|
|
1646
1765
|
}
|
|
1647
1766
|
|
|
1767
|
+
/**
|
|
1768
|
+
* Draws the two whole-archive bars at the top of General.
|
|
1769
|
+
*
|
|
1770
|
+
* Into the canvases that are already there rather than by re-rendering
|
|
1771
|
+
* the pane: General carries the category box and the export buttons, and
|
|
1772
|
+
* replacing its markup under a three-second refresh would take the focus
|
|
1773
|
+
* out of whatever was being typed. A canvas can be redrawn in place.
|
|
1774
|
+
* @param {string} infoHash - The open archive.
|
|
1775
|
+
* @returns {Promise<void>} - Resolves once drawn, or quietly if it cannot be.
|
|
1776
|
+
*/
|
|
1777
|
+
async function renderPieceBars(infoHash) {
|
|
1778
|
+
const holder = $('entry-pieces');
|
|
1779
|
+
if (!holder) return;
|
|
1780
|
+
|
|
1781
|
+
const width = Math.max(256, Math.min(2048, holder.clientWidth || 1000));
|
|
1782
|
+
let info;
|
|
1783
|
+
try {
|
|
1784
|
+
info = await api(
|
|
1785
|
+
`/api/torrents/${infoHash}/pieces?buckets=${width}`,
|
|
1786
|
+
);
|
|
1787
|
+
} catch {
|
|
1788
|
+
// An engine that cannot describe pieces is not a fault worth a
|
|
1789
|
+
// message here — the rest of General is still the answer somebody
|
|
1790
|
+
// opened this for.
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
if (!info || info.error || !info.have) return;
|
|
1794
|
+
|
|
1795
|
+
const held = info.haveCount ?? 0;
|
|
1796
|
+
const pctHeld = info.numPieces ? (held / info.numPieces) * 100 : 0;
|
|
1797
|
+
holder.querySelector('[data-value="have"]').textContent =
|
|
1798
|
+
`${pctHeld.toFixed(1)}%`;
|
|
1799
|
+
holder.querySelector('[data-value="availability"]').textContent = (
|
|
1800
|
+
info.distributedCopies ?? 0
|
|
1801
|
+
).toFixed(3);
|
|
1802
|
+
holder.querySelector('[data-role="piecenote"]').textContent =
|
|
1803
|
+
`${info.numPieces} pieces of ${bytes(info.pieceLength)}, drawn in ` +
|
|
1804
|
+
`${info.buckets} columns — so one column is ` +
|
|
1805
|
+
`${Math.ceil(info.numPieces / info.buckets)} piece(s), and is only ` +
|
|
1806
|
+
'shown as held when all of them are.';
|
|
1807
|
+
|
|
1808
|
+
drawPieceBar(holder.querySelector('[data-bar="have"]'), info.have, 'have');
|
|
1809
|
+
drawPieceBar(
|
|
1810
|
+
holder.querySelector('[data-bar="availability"]'),
|
|
1811
|
+
info.availability,
|
|
1812
|
+
'availability',
|
|
1813
|
+
);
|
|
1814
|
+
holder.hidden = false;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1648
1817
|
async function renderDetail(infoHash, keepTab = true) {
|
|
1649
1818
|
if (!keepTab) activeTab = 'general';
|
|
1650
1819
|
const panel = $('detail');
|
|
@@ -1668,18 +1837,36 @@
|
|
|
1668
1837
|
const summary = entry.pmtiles ?? {};
|
|
1669
1838
|
const mode = entry.mode ?? 'mirror';
|
|
1670
1839
|
|
|
1840
|
+
// No heading. The panel opens directly under the row that names the
|
|
1841
|
+
// archive, so a title here repeated what the eye had just read one
|
|
1842
|
+
// line above it. Named to assistive technology instead, which has no
|
|
1843
|
+
// "just above" to rely on.
|
|
1844
|
+
panel.setAttribute('role', 'region');
|
|
1845
|
+
panel.setAttribute('aria-label', entry.name);
|
|
1671
1846
|
panel.innerHTML = `
|
|
1672
|
-
<h2>${escapeHtml(entry.name)}</h2>
|
|
1673
1847
|
<div class="tabs">
|
|
1674
1848
|
<button data-tab="general" class="on">General</button>
|
|
1675
1849
|
<button data-tab="trackers">Trackers</button>
|
|
1676
1850
|
<button data-tab="peers">Peers</button>
|
|
1677
|
-
<button data-tab="pieces">Pieces</button>
|
|
1678
1851
|
<button data-tab="sources">HTTP sources</button>
|
|
1679
1852
|
<button data-tab="content">Content</button>
|
|
1680
1853
|
</div>
|
|
1681
1854
|
|
|
1682
1855
|
<div data-pane="general">
|
|
1856
|
+
<div id="entry-pieces" hidden>
|
|
1857
|
+
<div class="piecerow">
|
|
1858
|
+
<span class="label">Downloaded</span>
|
|
1859
|
+
<canvas class="piecebar" data-bar="have"></canvas>
|
|
1860
|
+
<span class="value" data-value="have"></span>
|
|
1861
|
+
</div>
|
|
1862
|
+
<div class="piecerow">
|
|
1863
|
+
<span class="label" title="The rarest piece in each column: one piece nobody has answers "can this still be completed", however well supplied its neighbours are. Connected peers only, not this node — so an archive only this node holds shows an empty bar, which is the truth about the swarm rather than about the file.">Availability</span>
|
|
1864
|
+
<canvas class="piecebar" data-bar="availability"></canvas>
|
|
1865
|
+
<span class="value" data-value="availability"></span>
|
|
1866
|
+
</div>
|
|
1867
|
+
<div class="sub" data-role="piecenote" style="margin:0.6rem 0 1rem"></div>
|
|
1868
|
+
</div>
|
|
1869
|
+
|
|
1683
1870
|
<div class="grid">
|
|
1684
1871
|
<div><span>infohash</span><b>${escapeHtml(entry.infoHash)}</b></div>
|
|
1685
1872
|
<div><span>size</span><b>${bytes(entry.size)}</b></div>
|
|
@@ -1803,7 +1990,6 @@
|
|
|
1803
1990
|
|
|
1804
1991
|
<div data-pane="trackers" hidden><div class="sub">loading…</div></div>
|
|
1805
1992
|
<div data-pane="peers" hidden><div class="sub">loading…</div></div>
|
|
1806
|
-
<div data-pane="pieces" hidden><div class="sub">loading…</div></div>
|
|
1807
1993
|
<div data-pane="sources" hidden><div class="sub">loading…</div></div>
|
|
1808
1994
|
<div data-pane="content" hidden><div class="sub">loading…</div></div>`;
|
|
1809
1995
|
|
|
@@ -2154,6 +2340,7 @@ Every piece is hashed against the ` +
|
|
|
2154
2340
|
}
|
|
2155
2341
|
};
|
|
2156
2342
|
|
|
2343
|
+
renderPieceBars(infoHash);
|
|
2157
2344
|
if (servable) renderPreview(entry);
|
|
2158
2345
|
}
|
|
2159
2346
|
|