pmtiles-swarm 0.27.0 โ†’ 0.29.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,56 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.29.0
11
+ ### โœจ Features and improvements
12
+ - **A connection indicator in the header, for whether the swarm can reach this node.** A node
13
+ nothing can connect to still downloads and still uploads โ€” it dials out and its transfers work โ€”
14
+ so none of its own traffic reveals that half the swarm can never start a conversation with it.
15
+ What it loses is invisible and permanent: fewer peers, slower starts, and a seed nobody fetches
16
+ from unless they were introduced to it first.
17
+
18
+ Green when something has connected inward, amber when the node is listening and nothing ever
19
+ has, red when it is not listening at all. libtorrent answers from
20
+ `net.has_incoming_connections`, which latches for the session, so a reachable node that is
21
+ merely quiet stays green rather than flickering when its last peer leaves. WebTorrent keeps no
22
+ such gauge, so it is assembled from the wires โ€” each carries the direction it was made in โ€” and
23
+ latched for the same reason.
24
+
25
+ Reported per engine rather than blended. Two engines means two listening ports, forwarded
26
+ separately, and one can be reachable while the other is not; a single verdict would have to hide
27
+ the one somebody needs to fix. The header shows the primary and names both on hover.
28
+
29
+ The amber state reads "no incoming yet", not "firewalled". On a node no peer has tried those are
30
+ the same observation, and claiming the first would put a warning on a node that is merely new.
31
+ An engine that cannot answer hides the indicator instead of showing red โ€” not being able to ask
32
+ is not the same as being unreachable, and a red light on a healthy node is worse than none.
33
+
34
+ Needs pmtiles-torrent 0.5.0 for the libtorrent engine; against an older sidecar the indicator
35
+ simply stays hidden.
36
+
37
+ ### ๐Ÿž Bug fixes
38
+
39
+ ## 0.28.0
40
+ ### โœจ Features and improvements
41
+ - **The archive list can be searched and sorted, and says when each archive was added.** `Added` is
42
+ a column now โ€” `createdAt` has always been on every entry and returned by `/api/catalog`, it was
43
+ simply never shown โ€” with a date for anything older than today and a time for today, since the
44
+ question a list answers is which of these is recent rather than exactly when each arrived.
45
+
46
+ Beside it, the same filter and sort the public page has: by name, infohash or category, ordered
47
+ by newest added, oldest added, name or size โ€” and by download speed, upload speed or share
48
+ ratio, which are read off the live status the poll refreshes, so rows reorder themselves under
49
+ those every few seconds. That is what a torrent client does and what choosing "download speed"
50
+ asks for, and also why none of them is the default. Newest added is, because a list read
51
+ straight after adding something should have that thing at the top.
52
+
53
+ Both happen inside the render rather than where the data arrives. The list refreshes every three
54
+ seconds; filtering at the fetch would either clear what had been typed on each poll or refetch
55
+ the whole catalog on every keystroke. And the count beside the box says `4 of 37` while a filter
56
+ is narrowing things, because an empty table and a table filtered down to nothing look identical.
57
+
58
+ ### ๐Ÿž Bug fixes
59
+
10
60
  ## 0.27.0
11
61
  ### โœจ Features and improvements
12
62
  - **Bandwidth history per archive, kept across restarts.** The tile side of this question already
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.27.0",
3
+ "version": "0.29.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
@@ -530,6 +530,17 @@ export function createApp({
530
530
  ? config.incompleteSuffix || null
531
531
  : null,
532
532
  engine: { name: engine.name, ok: engineOk, error: engineError },
533
+ // Whether the swarm can reach us, as opposed to whether we can reach
534
+ // it. A node that cannot be connected to still downloads and still
535
+ // uploads, so nothing about its own traffic reveals that half the
536
+ // swarm can never start a conversation with it.
537
+ reachability:
538
+ typeof engine.reachability === 'function'
539
+ ? await engine.reachability().catch((error) => ({
540
+ state: 'unknown',
541
+ error: error.message,
542
+ }))
543
+ : null,
533
544
  archives: catalog.list().length,
534
545
  categories: catalog.categories(),
535
546
  watching: config.watch.map((w) => w.path),
@@ -303,6 +303,29 @@ export class CompositeEngine {
303
303
  * Every archive, with the peers and speeds of all engines added together.
304
304
  * @returns {Promise<import('./types.js').TorrentStatus[]>} - Merged status.
305
305
  */
306
+ /**
307
+ * Reachability, per engine rather than blended into one verdict.
308
+ *
309
+ * Two engines means two listening ports, and they are forwarded separately.
310
+ * One can be reachable while the other is not, so a single answer would have
311
+ * to either pick a winner or average two facts into something that is not
312
+ * true of either -- and the one it got wrong is the one somebody needs to
313
+ * fix. The primary leads because it is the engine that downloads.
314
+ * @returns {Promise<object>} - `{state, engines}`.
315
+ */
316
+ async reachability() {
317
+ const engines = [];
318
+ for (const engine of [this.#primary, ...this.#secondaries]) {
319
+ if (typeof engine.reachability !== 'function') continue;
320
+ const report = await engine.reachability().catch((error) => ({
321
+ state: 'unknown',
322
+ error: error.message,
323
+ }));
324
+ if (report) engines.push({ engine: engine.name, ...report });
325
+ }
326
+ return { ...(engines[0] ?? { state: 'unknown' }), engines };
327
+ }
328
+
306
329
  async list() {
307
330
  if (this.#stopping) return [];
308
331
  const primary = await this.#primary.list();
@@ -284,6 +284,25 @@ export class LibtorrentEngine {
284
284
  return result?.trackers ?? [];
285
285
  }
286
286
 
287
+ /**
288
+ * Whether peers can open a connection to this node, or only the reverse.
289
+ *
290
+ * See the sidecar's op_reachability for what the three states mean and why
291
+ * the middle one is "unproven" rather than "firewalled": on a node with no
292
+ * peers, blocked and untried are the same observation.
293
+ * @returns {Promise<object|null>} - The report, or null when unavailable.
294
+ */
295
+ async reachability() {
296
+ if (this.#stopping) return null;
297
+ try {
298
+ return await this.#call('reachability', {});
299
+ } catch (error) {
300
+ // An engine that cannot answer is not an engine that is unreachable, and
301
+ // reporting it as offline would put a red light on a healthy node.
302
+ return { state: 'unknown', error: error.message };
303
+ }
304
+ }
305
+
287
306
  async list() {
288
307
  // A node that is shutting down still has a console polling it and a sweep
289
308
  // or two in flight. Answering "the sidecar exited" to each of them fills
@@ -60,6 +60,7 @@
60
60
  * @property {(filePath: string, options?: object) => Promise<object>} [createTorrent] - Builds a torrent from a local file, where the engine can do it better than the default โ€” libtorrent produces hybrid v1+v2, which create-torrent cannot.
61
61
  * @property {(infoHash: string) => Promise<object[]>} [trackerStatus] - Per-tracker announce results, where the engine keeps them.
62
62
  * @property {(infoHash: string) => Promise<Uint8Array | null>} [metadata] - The torrent's metainfo once known, so an archive joined by magnet can be written down rather than re-fetched over BEP 9 on every start.
63
+ * @property {() => Promise<object | null>} [reachability] - Whether peers can open a connection to this node, or only the reverse. Three states: `open` (something has connected inward), `unproven` (listening, but nothing ever has) and `offline` (not listening at all). The middle is deliberately not called firewalled -- on a node with no peers, blocked and untried are the same observation.
63
64
  * @property {() => Promise<void>} destroy - Releases resources.
64
65
  */
65
66
 
@@ -34,6 +34,7 @@ import {
34
34
  export class WebTorrentSeedEngine {
35
35
  #options;
36
36
  #client = null;
37
+ #everIncoming = false;
37
38
  /**
38
39
  * A client error that means nothing will ever work.
39
40
  *
@@ -319,6 +320,43 @@ export class WebTorrentSeedEngine {
319
320
  * Lists every torrent the client holds.
320
321
  * @returns {Promise<import('./types.js').TorrentStatus[]>} - Normalised statuses.
321
322
  */
323
+ /**
324
+ * Whether peers can open a connection to this node, or only the reverse.
325
+ *
326
+ * WebTorrent keeps no equivalent of libtorrent's has_incoming_connections
327
+ * gauge, so it is assembled from the wires: every one carries the direction
328
+ * it was made in, and a type ending "Incoming" is somebody who reached us.
329
+ *
330
+ * Latched rather than sampled, which is the whole reason for the field. A
331
+ * wire is gone the moment the peer leaves, so asking "is one open now" would
332
+ * report a reachable node as unproven every time it went quiet. What is worth
333
+ * knowing is whether it has ever happened at all.
334
+ * @returns {Promise<object|null>} - The report, or null when not started.
335
+ */
336
+ async reachability() {
337
+ const client = this.#client;
338
+ if (!client) return null;
339
+
340
+ if (!this.#everIncoming) {
341
+ this.#everIncoming = (client.torrents ?? []).some((torrent) =>
342
+ (torrent.wires ?? []).some((wire) =>
343
+ String(wire.type ?? '').endsWith('Incoming'),
344
+ ),
345
+ );
346
+ }
347
+
348
+ const listening = Boolean(client.listening);
349
+ return {
350
+ state: !listening ? 'offline' : this.#everIncoming ? 'open' : 'unproven',
351
+ listening,
352
+ port: client.torrentPort ?? null,
353
+ peersConnected: (client.torrents ?? []).reduce(
354
+ (sum, torrent) => sum + (torrent.numPeers ?? 0),
355
+ 0,
356
+ ),
357
+ };
358
+ }
359
+
322
360
  async list() {
323
361
  if (!this.#client) return [];
324
362
  return this.#client.torrents.map((torrent) => this.#normalise(torrent));
@@ -49,6 +49,19 @@
49
49
  h2 { font-size: 0.95rem; margin: 0 0 0.6rem; font-weight: 600; }
50
50
  .status { color: var(--muted); font-size: 0.85rem; }
51
51
  .status b { color: var(--fg); font-weight: 600; }
52
+ /* A dot rather than an image: it inherits the theme, needs no asset,
53
+ and stays legible at the size a header allows. */
54
+ .reach {
55
+ display: inline-flex; align-items: center; gap: 0.35rem;
56
+ font-size: 0.8rem; color: var(--muted); cursor: default;
57
+ }
58
+ .reach::before {
59
+ content: ""; width: 0.6rem; height: 0.6rem; border-radius: 50%;
60
+ background: var(--muted);
61
+ }
62
+ .reach.open::before { background: var(--ok); }
63
+ .reach.unproven::before { background: var(--warn); }
64
+ .reach.offline::before { background: var(--bad); }
52
65
  nav { margin-left: auto; display: flex; gap: 0.35rem; }
53
66
  nav button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
54
67
  main { padding: 1.25rem; }
@@ -305,6 +318,7 @@
305
318
  <header>
306
319
  <h1>pmtiles-swarm</h1>
307
320
  <div class="status" id="status">connectingโ€ฆ</div>
321
+ <span id="reach" class="reach" hidden></span>
308
322
  <button id="speed-toggle" class="speed" hidden title="Switch between the normal and alternative speed limits"></button>
309
323
  <nav>
310
324
  <button id="tab-archives" class="on">Archives</button>
@@ -324,12 +338,36 @@
324
338
  <button type="button">Feed</button>
325
339
  </a>
326
340
  </div>
341
+ <div class="bar">
342
+ <input
343
+ id="archive-filter"
344
+ type="search"
345
+ placeholder="Filter by name, infohash or category"
346
+ autocomplete="off"
347
+ spellcheck="false"
348
+ style="flex:1;min-width:12rem"
349
+ />
350
+ <label>
351
+ Sort
352
+ <select id="archive-sort">
353
+ <option value="added">newest added</option>
354
+ <option value="oldest">oldest added</option>
355
+ <option value="name">name</option>
356
+ <option value="largest">largest</option>
357
+ <option value="down">download speed</option>
358
+ <option value="up">upload speed</option>
359
+ <option value="ratio">share ratio</option>
360
+ </select>
361
+ </label>
362
+ <span class="sub" id="archive-count"></span>
363
+ </div>
327
364
 
328
365
  <table>
329
366
  <thead>
330
367
  <tr>
331
368
  <th>Archive</th>
332
369
  <th>Size</th>
370
+ <th>Added</th>
333
371
  <th>Mode</th>
334
372
  <th>Origin</th>
335
373
  <th>Progress</th>
@@ -710,6 +748,56 @@
710
748
  const rate = (n) => (n > 0 ? `${bytes(n)}/s` : 'โ€”');
711
749
  const pct = (n) => `${Math.round((n ?? 0) * 100)}%`;
712
750
 
751
+ /**
752
+ * When an archive was added, as something short enough for a column.
753
+ *
754
+ * A date on its own for anything older than today, and a time for
755
+ * today: the question a list answers is "which of these is recent", and
756
+ * a full timestamp on every row is harder to scan than either.
757
+ * @param {string} value - ISO timestamp.
758
+ * @returns {string} - Markup for the cell.
759
+ */
760
+ const added = (value) => {
761
+ if (!value) return '<span class="sub">โ€”</span>';
762
+ const when = new Date(value);
763
+ if (Number.isNaN(when.getTime())) return '<span class="sub">โ€”</span>';
764
+ const today = new Date().toDateString() === when.toDateString();
765
+ return `<span title="${escapeHtml(when.toLocaleString())}">${
766
+ today
767
+ ? when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
768
+ : when.toLocaleDateString()
769
+ }</span>`;
770
+ };
771
+
772
+ // The same three questions the public page asks, against an admin row.
773
+ // Newest first by default: a list read after adding something is a list
774
+ // where the thing just added should be at the top.
775
+ /**
776
+ * A live figure off an archive's status, or zero.
777
+ *
778
+ * An archive the engine has not reported on yet has no status at all,
779
+ * and comparing undefined gives NaN -- which makes a sort silently do
780
+ * nothing rather than fail, so it is worth the guard.
781
+ * @param {object} entry - Catalog entry with its status attached.
782
+ * @param {string} field - Which figure.
783
+ * @returns {number} - The value, or zero.
784
+ */
785
+ const speedOf = (entry, field) => Number(entry?.status?.[field] ?? 0) || 0;
786
+
787
+ const archiveSorters = {
788
+ added: (a, b) => new Date(b.createdAt ?? 0) - new Date(a.createdAt ?? 0),
789
+ oldest: (a, b) => new Date(a.createdAt ?? 0) - new Date(b.createdAt ?? 0),
790
+ name: (a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')),
791
+ largest: (a, b) => (b.size ?? 0) - (a.size ?? 0),
792
+ // Live values, read off the status the poll refreshes. Rows reorder
793
+ // themselves every few seconds under these, which is what a torrent
794
+ // client does and what somebody choosing "download speed" is asking
795
+ // for -- but it is also why they are not the default.
796
+ down: (a, b) => speedOf(b, 'downloadSpeed') - speedOf(a, 'downloadSpeed'),
797
+ up: (a, b) => speedOf(b, 'uploadSpeed') - speedOf(a, 'uploadSpeed'),
798
+ ratio: (a, b) => speedOf(b, 'ratio') - speedOf(a, 'ratio'),
799
+ };
800
+
713
801
  let toastTimer;
714
802
  const toast = (message) => {
715
803
  $('toast').textContent = message;
@@ -913,6 +1001,50 @@
913
1001
  }
914
1002
  };
915
1003
 
1004
+ /**
1005
+ * The connection indicator: can the swarm reach us, or only we it?
1006
+ *
1007
+ * A node nothing can connect to still downloads and still uploads, so
1008
+ * none of its own traffic reveals the problem -- it simply gets fewer
1009
+ * peers, slower starts, and nobody fetching from it unless introduced
1010
+ * first. That is the whole reason for showing it at all.
1011
+ *
1012
+ * The middle state says "no incoming yet" rather than "firewalled",
1013
+ * because those are the same observation on a node no peer has tried.
1014
+ * Calling it firewalled would put a warning on a healthy node that is
1015
+ * merely new or quiet, which is worse than saying less.
1016
+ *
1017
+ * @param {object} report - status.reachability.
1018
+ * @returns {void}
1019
+ */
1020
+ function renderReach(report) {
1021
+ const host = $('reach');
1022
+ if (!report || !report.state || report.state === 'unknown') {
1023
+ host.hidden = true;
1024
+ return;
1025
+ }
1026
+ host.hidden = false;
1027
+ host.className = `reach ${report.state}`;
1028
+
1029
+ const label = {
1030
+ open: 'reachable',
1031
+ unproven: 'no incoming yet',
1032
+ offline: 'not listening',
1033
+ }[report.state];
1034
+ host.textContent = label;
1035
+
1036
+ const detail = (report.engines ?? [report]).map((one) => {
1037
+ const which = one.engine ? `${one.engine}: ` : '';
1038
+ const port = one.port ? ` on port ${one.port}` : '';
1039
+ if (one.state === 'offline') return `${which}not listening`;
1040
+ if (one.state === 'open') {
1041
+ return `${which}${one.incomingConnections ?? 0} peer(s) have connected in${port}`;
1042
+ }
1043
+ return `${which}listening${port}, but nothing has connected in yet โ€” it may be firewalled, or simply untried`;
1044
+ });
1045
+ host.title = detail.join('\n');
1046
+ }
1047
+
916
1048
  async function refresh() {
917
1049
  try {
918
1050
  const [status, list, speed, adds] = await Promise.all([
@@ -928,6 +1060,7 @@
928
1060
  // nothing renames it. The engine decides, not the setting alone.
929
1061
  incompleteMarker = status.incompleteMarker ?? null;
930
1062
  const engine = status.engine;
1063
+ renderReach(status.reachability);
931
1064
  $('status').innerHTML =
932
1065
  `engine <b>${engine.name}</b> ${engine.ok ? 'ready' : 'unavailable'}` +
933
1066
  ` ยท <b>${status.archives}</b> archives`;
@@ -1004,9 +1137,34 @@
1004
1137
  function renderRows() {
1005
1138
  const rows = $('rows');
1006
1139
  rows.innerHTML = '';
1140
+
1141
+ // Filtered and sorted here rather than where the data arrives, so the
1142
+ // three-second poll keeps refreshing what is on screen without
1143
+ // resetting what was typed. `archives` stays the node's answer; this
1144
+ // is only the view of it.
1145
+ const needle = ($('archive-filter')?.value ?? '').trim().toLowerCase();
1146
+ const order = $('archive-sort')?.value ?? 'added';
1147
+ const shown = archives
1148
+ .filter(
1149
+ (entry) =>
1150
+ needle === '' ||
1151
+ String(entry.name ?? '').toLowerCase().includes(needle) ||
1152
+ String(entry.infoHash ?? '').toLowerCase().includes(needle) ||
1153
+ (entry.categories ?? []).some((tag) =>
1154
+ String(tag).toLowerCase().includes(needle),
1155
+ ),
1156
+ )
1157
+ .sort(archiveSorters[order] ?? archiveSorters.added);
1158
+
1007
1159
  $('empty').hidden = archives.length > 0;
1160
+ // Says when a filter is hiding things, because an empty table and a
1161
+ // table filtered down to nothing look identical otherwise.
1162
+ $('archive-count').textContent =
1163
+ needle === ''
1164
+ ? ''
1165
+ : `${shown.length} of ${archives.length}`;
1008
1166
 
1009
- for (const entry of archives) {
1167
+ for (const entry of shown) {
1010
1168
  const s = entry.status ?? {};
1011
1169
  const progress = s.progress ?? 0;
1012
1170
  const mode = entry.mode ?? 'mirror';
@@ -1024,6 +1182,7 @@
1024
1182
  ? `<div class="sub">${escapeHtml(entry.kind)} ยท not servable</div>`
1025
1183
  : ''
1026
1184
  }</td>
1185
+ <td>${added(entry.createdAt)}</td>
1027
1186
  <td><span class="pill ${mode}">${mode}</span></td>
1028
1187
  <td>${originCell(entry)}</td>
1029
1188
  <td>
@@ -4204,6 +4363,8 @@
4204
4363
  $('traffic-live').onchange = trafficPoll;
4205
4364
  trafficPoll();
4206
4365
 
4366
+ $('archive-filter').addEventListener('input', renderRows);
4367
+ $('archive-sort').addEventListener('change', renderRows);
4207
4368
  $('traffic-window').onchange = () => loadSwarmTraffic();
4208
4369
  $('traffic-reset').onclick = async () => {
4209
4370
  // Deliberately separate from reading: a page polling the endpoint must