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 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
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.41.2",
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
  *
@@ -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: -0.25rem 0 1rem;
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 { 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,92 +1648,80 @@
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
 
@@ -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 &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>
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