pmtiles-swarm 0.41.3 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.44.0
11
+ ### ✨ Features and improvements
12
+ - **Web seeds are added where they are listed.** The **HTTP sources** tab now has a field and an Add
13
+ button under the list, so adding one shows what is already there and what the addition did. It
14
+ replaces the **Add web seed** button in Actions, which opened a `prompt()` a long way from the list
15
+ it changed — one way to do this rather than two.
16
+
17
+ The tab now also says why this is safe on a published archive: `url-list` sits outside the info
18
+ dictionary, so the infohash is unchanged and every magnet and peer already relying on it keeps
19
+ working. The result distinguishes "added" from "the engine has taken it", which are different
20
+ claims and the second is the one that matters.
21
+
22
+ ### 🐞 Bug fixes
23
+
24
+ ## 0.43.0
25
+ ### ✨ Features and improvements
26
+ - **Six detail tabs are now five, and the two bars that matter lead the General tab.** How much of an
27
+ archive is here, and whether it can still be completed, are the first things anybody opening one
28
+ wants — and they were a tab away, above an explanation of how the drawing works. They now sit above
29
+ the infohash row, where the question is asked.
30
+
31
+ What that left on the Pieces tab was per-peer data, which is what the Peers tab already was. The two
32
+ are one: each peer's row shows what it holds beside how it is connected and how fast, joined from
33
+ the two endpoints that each knew half of it. A peer either side knows about still gets a row, since
34
+ half an answer about a peer beats leaving it out.
35
+
36
+ The General bars are redrawn into their canvases rather than by re-rendering the pane, so the
37
+ category box below them keeps whatever was being typed across the three-second refresh.
38
+
39
+ ### 🐞 Bug fixes
40
+
41
+ ## 0.42.0
42
+ ### ✨ Features and improvements
43
+ - **Recheck all, Pause all and Resume all**, in the archives toolbar. Each does to every archive what
44
+ the per-archive action does to one — sequentially, because rechecking is disk-bound and firing
45
+ twenty at once at a library of planet builds is a way to make a node unresponsive while it works.
46
+
47
+ Each skips what does not need doing and reports how many were done, skipped and failed; a failure
48
+ is counted and named rather than thrown, since these run over archives nobody has looked at
49
+ individually and stopping at the first would leave the rest in an unknown state. **Recheck all** is
50
+ the one worth knowing about after a disk repair: every other answer about how much of an archive is
51
+ here comes from something written down earlier, and this is the only thing that goes and looks.
52
+ `POST /api/library/recheck`, `/api/library/pause` and `/api/library/resume` are the same three from
53
+ a script.
54
+ - **A node can be taken out of rotation without being stopped.** `offline` — or the **Take offline**
55
+ switch in the console header, beside the engine and the reachability dot, where the things that
56
+ describe the node rather than an archive live — makes `/health` answer `503` with `status: "offline"`, which is what a load balancer
57
+ reads to stop sending traffic here. It is answered before the engine is asked, so a node stays
58
+ drained whatever the engine happens to think.
59
+
60
+ Nothing else changes: seeding, the console and the library carry on, because draining traffic and
61
+ stopping work are separate decisions and one switch doing both would mean a node could not be
62
+ drained without also being idled. It is stored in the configuration rather than in memory, so it
63
+ survives the restart you were probably about to do.
64
+
65
+ ### 🐞 Bug fixes
66
+
10
67
  ## 0.41.3
11
68
  ### ✨ Features and improvements
12
69
  - **The details panel no longer repeats the archive's name.** It opens directly under the row that
@@ -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
@@ -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
- ### Stopping one that is running
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
@@ -132,6 +151,22 @@ Adding from a **local path** never publishes a seed unless you ask: a web seed
132
151
  appears only when `webSeedBase` is set, and `publishDir` moves the file without
133
152
  implying one.
134
153
 
154
+ ### Adding one later
155
+
156
+ An archive already in circulation can be given a web seed at any time, from the
157
+ **HTTP sources** tab of its details or with
158
+ `POST /api/torrents/<infohash>/webseeds`. This is safe on a published torrent:
159
+ `url-list` sits outside the info dictionary, so the infohash does not change and
160
+ every magnet, tracker and peer already relying on it keeps working.
161
+
162
+ The URL has to serve the whole archive, byte for byte, under the name the torrent
163
+ gives it. A seed that answers with anything else is not a faster path to the
164
+ data — peers will fetch from it, fail the piece hash, and fall back to the swarm
165
+ having wasted the transfer.
166
+
167
+ Worth doing whenever an archive gets a public home after the fact: a swarm with
168
+ one web seed starts cold in seconds rather than waiting for a peer to appear.
169
+
135
170
  ## Building from a feed of source data
136
171
 
137
172
  Subscribing is not only for finished archives. The OpenStreetMap project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.41.3",
3
+ "version": "0.44.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
  *
@@ -224,6 +224,8 @@
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;
@@ -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 { border-color: var(--line); color: var(--muted); }
343
- button.speed.on { border-color: var(--warn); color: var(--warn); }
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 &quot;not right now&quot;, not &quot;not any more&quot;.">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(['pieces', 'peers']);
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,104 +1648,143 @@
1517
1648
  }
1518
1649
 
1519
1650
  if (name === 'peers') {
1520
- const answer = await api(`/api/torrents/${infoHash}/peers`);
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
- peers.map((peer) => [
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
- if (name === 'pieces') {
1548
- const width = Math.max(256, Math.min(2048, pane.clientWidth || 1000));
1549
- const info = await api(
1550
- `/api/torrents/${infoHash}/pieces?buckets=${width}&peers=true`,
1551
- );
1552
- if (info.error) {
1553
- pane.innerHTML = `<div class="sub">${escapeHtml(info.error)}</div>`;
1554
- return;
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
 
1609
1728
  if (name === 'sources') {
1610
1729
  const seeds = entry.webSeeds ?? [];
1611
1730
  pane.innerHTML =
1612
- table(['URL'], seeds.map((url) => [escapeHtml(url)])) +
1613
- `<div class="sub" style="margin-top:0.5rem">
1731
+ table(
1732
+ ['URL'],
1733
+ seeds.map((url) => [escapeHtml(url)]),
1734
+ ) +
1735
+ `<div style="display:flex; gap:0.4rem; margin-top:0.75rem">
1736
+ <input
1737
+ id="seed-url"
1738
+ class="grow"
1739
+ placeholder="https://example.org/${escapeHtml(entry.name)}"
1740
+ spellcheck="false"
1741
+ />
1742
+ <button id="seed-add">Add</button>
1743
+ </div>
1744
+ <div class="sub" style="margin-top:0.5rem">
1614
1745
  A web seed serves the archive over plain HTTP, which is what
1615
1746
  makes it usable before it has peers — and the difference
1616
- between a cold tile taking tens of seconds and under one.
1747
+ between a cold tile taking tens of seconds and under one. One
1748
+ can be added at any time: <code>url-list</code> sits outside
1749
+ the info dictionary, so the infohash is unchanged and every
1750
+ magnet and peer already relying on it keeps working. The URL
1751
+ must serve the whole archive, byte for byte, under the name
1752
+ the torrent gives it.
1617
1753
  </div>`;
1754
+
1755
+ // Attached here rather than in the panel, because this pane is
1756
+ // rebuilt whenever it is shown and the handler has to survive that.
1757
+ // Safe: sources is not one of the panes the refresh redraws.
1758
+ const add = async () => {
1759
+ const url = pane.querySelector('#seed-url').value.trim();
1760
+ if (!url) return;
1761
+ const button = pane.querySelector('#seed-add');
1762
+ button.disabled = true;
1763
+ try {
1764
+ const result = await api(
1765
+ `/api/torrents/${infoHash}/webseeds`,
1766
+ { method: 'POST', body: { webSeeds: [url] } },
1767
+ );
1768
+ // "Added" and "the engine is using it" are different claims,
1769
+ // and the second is the one somebody adding a seed wants.
1770
+ toast(
1771
+ result.live
1772
+ ? 'web seed added and live'
1773
+ : 'web seed added; the engine has not taken it yet',
1774
+ );
1775
+ renderDetail(infoHash);
1776
+ } catch (error) {
1777
+ toast(error.message);
1778
+ button.disabled = false;
1779
+ }
1780
+ };
1781
+
1782
+ pane.querySelector('#seed-add').onclick = add;
1783
+ pane.querySelector('#seed-url').onkeydown = (event) => {
1784
+ // Enter, because a single field with a button beside it is a
1785
+ // form in everything but name.
1786
+ if (event.key === 'Enter') add();
1787
+ };
1618
1788
  return;
1619
1789
  }
1620
1790
 
@@ -1645,6 +1815,56 @@
1645
1815
  }
1646
1816
  }
1647
1817
 
1818
+ /**
1819
+ * Draws the two whole-archive bars at the top of General.
1820
+ *
1821
+ * Into the canvases that are already there rather than by re-rendering
1822
+ * the pane: General carries the category box and the export buttons, and
1823
+ * replacing its markup under a three-second refresh would take the focus
1824
+ * out of whatever was being typed. A canvas can be redrawn in place.
1825
+ * @param {string} infoHash - The open archive.
1826
+ * @returns {Promise<void>} - Resolves once drawn, or quietly if it cannot be.
1827
+ */
1828
+ async function renderPieceBars(infoHash) {
1829
+ const holder = $('entry-pieces');
1830
+ if (!holder) return;
1831
+
1832
+ const width = Math.max(256, Math.min(2048, holder.clientWidth || 1000));
1833
+ let info;
1834
+ try {
1835
+ info = await api(
1836
+ `/api/torrents/${infoHash}/pieces?buckets=${width}`,
1837
+ );
1838
+ } catch {
1839
+ // An engine that cannot describe pieces is not a fault worth a
1840
+ // message here — the rest of General is still the answer somebody
1841
+ // opened this for.
1842
+ return;
1843
+ }
1844
+ if (!info || info.error || !info.have) return;
1845
+
1846
+ const held = info.haveCount ?? 0;
1847
+ const pctHeld = info.numPieces ? (held / info.numPieces) * 100 : 0;
1848
+ holder.querySelector('[data-value="have"]').textContent =
1849
+ `${pctHeld.toFixed(1)}%`;
1850
+ holder.querySelector('[data-value="availability"]').textContent = (
1851
+ info.distributedCopies ?? 0
1852
+ ).toFixed(3);
1853
+ holder.querySelector('[data-role="piecenote"]').textContent =
1854
+ `${info.numPieces} pieces of ${bytes(info.pieceLength)}, drawn in ` +
1855
+ `${info.buckets} columns — so one column is ` +
1856
+ `${Math.ceil(info.numPieces / info.buckets)} piece(s), and is only ` +
1857
+ 'shown as held when all of them are.';
1858
+
1859
+ drawPieceBar(holder.querySelector('[data-bar="have"]'), info.have, 'have');
1860
+ drawPieceBar(
1861
+ holder.querySelector('[data-bar="availability"]'),
1862
+ info.availability,
1863
+ 'availability',
1864
+ );
1865
+ holder.hidden = false;
1866
+ }
1867
+
1648
1868
  async function renderDetail(infoHash, keepTab = true) {
1649
1869
  if (!keepTab) activeTab = 'general';
1650
1870
  const panel = $('detail');
@@ -1679,12 +1899,25 @@
1679
1899
  <button data-tab="general" class="on">General</button>
1680
1900
  <button data-tab="trackers">Trackers</button>
1681
1901
  <button data-tab="peers">Peers</button>
1682
- <button data-tab="pieces">Pieces</button>
1683
1902
  <button data-tab="sources">HTTP sources</button>
1684
1903
  <button data-tab="content">Content</button>
1685
1904
  </div>
1686
1905
 
1687
1906
  <div data-pane="general">
1907
+ <div id="entry-pieces" hidden>
1908
+ <div class="piecerow">
1909
+ <span class="label">Downloaded</span>
1910
+ <canvas class="piecebar" data-bar="have"></canvas>
1911
+ <span class="value" data-value="have"></span>
1912
+ </div>
1913
+ <div class="piecerow">
1914
+ <span class="label" title="The rarest piece in each column: one piece nobody has answers &quot;can this still be completed&quot;, 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>
1915
+ <canvas class="piecebar" data-bar="availability"></canvas>
1916
+ <span class="value" data-value="availability"></span>
1917
+ </div>
1918
+ <div class="sub" data-role="piecenote" style="margin:0.6rem 0 1rem"></div>
1919
+ </div>
1920
+
1688
1921
  <div class="grid">
1689
1922
  <div><span>infohash</span><b>${escapeHtml(entry.infoHash)}</b></div>
1690
1923
  <div><span>size</span><b>${bytes(entry.size)}</b></div>
@@ -1794,7 +2027,6 @@
1794
2027
  <div class="actions">
1795
2028
  ${servable ? '<button id="warm">Warm region</button>' : ''}
1796
2029
  <button id="set-location">Set location…</button>
1797
- <button id="add-seed">Add web seed</button>
1798
2030
  <button id="clear-cache" ${mode === 'cache' ? '' : 'disabled title="only cache-mode archives have a cache to clear"'}>Clear cache</button>
1799
2031
  <button id="recheck" title="Hash the files on disk again and believe the result. For an archive that reads 0% next to a file that is plainly there, or one that claims to be complete and is not — every other figure comes from something written down earlier, and this is the only thing that goes and looks.">Recheck files</button>
1800
2032
  <button id="pause">${entry.paused ? 'Resume' : 'Pause'}</button>
@@ -1808,7 +2040,6 @@
1808
2040
 
1809
2041
  <div data-pane="trackers" hidden><div class="sub">loading…</div></div>
1810
2042
  <div data-pane="peers" hidden><div class="sub">loading…</div></div>
1811
- <div data-pane="pieces" hidden><div class="sub">loading…</div></div>
1812
2043
  <div data-pane="sources" hidden><div class="sub">loading…</div></div>
1813
2044
  <div data-pane="content" hidden><div class="sub">loading…</div></div>`;
1814
2045
 
@@ -2067,24 +2298,6 @@
2067
2298
  };
2068
2299
  };
2069
2300
 
2070
- $('add-seed').onclick = async () => {
2071
- const url = window.prompt(
2072
- 'Web seed URL (adding one does not change the infohash):',
2073
- `https://example.org/${entry.name}`,
2074
- );
2075
- if (!url) return;
2076
- try {
2077
- const result = await api(`/api/torrents/${infoHash}/webseeds`, {
2078
- method: 'POST',
2079
- body: { webSeeds: [url] },
2080
- });
2081
- toast(result.live ? 'web seed added and live' : 'web seed added');
2082
- renderDetail(infoHash);
2083
- } catch (error) {
2084
- toast(error.message);
2085
- }
2086
- };
2087
-
2088
2301
  $('clear-cache').onclick = async () => {
2089
2302
  if (!window.confirm('Discard this archive’s cached pieces?')) return;
2090
2303
  try {
@@ -2159,6 +2372,7 @@ Every piece is hashed against the ` +
2159
2372
  }
2160
2373
  };
2161
2374
 
2375
+ renderPieceBars(infoHash);
2162
2376
  if (servable) renderPreview(entry);
2163
2377
  }
2164
2378