pmtiles-swarm 0.15.2 β†’ 0.16.1

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,33 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.16.1
11
+ ### 🐞 Bug fixes
12
+ - **Client addresses are recorded without the IPv4-mapped prefix.** A dual-stack listener reports
13
+ IPv4 peers as `::ffff:172.16.1.2`, so the Traffic tab showed an address nobody types β€” and
14
+ would have counted one client twice had it reached the node over both stacks.
15
+
16
+ Only a display and counting matter: `trustProxy` matching is unaffected, since Express compares
17
+ mapped addresses against plain IPv4 entries correctly. Verified rather than assumed, because
18
+ `"trustProxy": "172.16.1.2, 172.16.1.3"` looking like it should not match a `::ffff:` socket is
19
+ exactly the sort of thing that would have been quietly wrong.
20
+
21
+ ## 0.16.0
22
+ ### ✨ Features and improvements
23
+ - **A Traffic tab in the console**, which is where the statistics added in 0.10.0 should have been
24
+ all along β€” until now the only thing on screen was a single "tiles served" figure on an
25
+ archive's detail, and the node-level report, the breakdowns and the recent requests had no user
26
+ interface at all.
27
+
28
+ It shows what this node has served: totals, then per archive with a zoom histogram, a status
29
+ breakdown, p50/p95 and how many distinct clients β€” and the last forty requests with address,
30
+ tile, status, size and duration. Refreshes every five seconds while the tab is open, with a
31
+ switch to stop it, and a reset button that is deliberately a separate action from reading, so a
32
+ page polling the endpoint can never erase the history it is drawing.
33
+
34
+ Behind a load balancer this is the only honest way to see how traffic is really distributed:
35
+ open it on each node and compare, rather than trusting what the balancer believes it sent.
36
+
10
37
  ## 0.15.2
11
38
  ### 🐞 Bug fixes
12
39
  - **A mutable magnet is named after its category rather than a build.** `dn=` was taken from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.15.2",
3
+ "version": "0.16.1",
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/tile-stats.js CHANGED
@@ -103,6 +103,12 @@ export class TileStats {
103
103
  const { infoHash, z, status } = entry;
104
104
  if (!infoHash) return;
105
105
 
106
+ // A dual-stack listener reports IPv4 peers as ::ffff:172.16.1.2, which is
107
+ // the same client written two ways -- so counting it as written would
108
+ // split one address across two entries the moment anything reached the
109
+ // node over plain IPv4 as well.
110
+ const ip = entry.ip ? entry.ip.replace(/^::ffff:/, '') : entry.ip;
111
+
106
112
  let archive = this.#archives.get(infoHash);
107
113
  if (!archive) {
108
114
  archive = {
@@ -135,20 +141,20 @@ export class TileStats {
135
141
  if (Number.isFinite(status)) {
136
142
  archive.byStatus.set(status, (archive.byStatus.get(status) ?? 0) + 1);
137
143
  }
138
- if (entry.ip) {
144
+ if (ip) {
139
145
  // Counted rather than listed: a busy node sees a handful of distinct
140
146
  // sources β€” the proxy, a few LAN clients β€” and the count is the answer
141
147
  // to "is this arriving directly or through HAProxy".
142
- const seen = archive.clients.get(entry.ip) ?? { requests: 0, bytes: 0 };
148
+ const seen = archive.clients.get(ip) ?? { requests: 0, bytes: 0 };
143
149
  seen.requests += 1;
144
150
  seen.bytes += bytes;
145
- archive.clients.set(entry.ip, seen);
151
+ archive.clients.set(ip, seen);
146
152
  }
147
153
 
148
154
  if (this.#limit === 0) return;
149
155
  const row = {
150
156
  at: archive.lastSeen,
151
- ip: entry.ip ?? null,
157
+ ip: ip ?? null,
152
158
  infoHash,
153
159
  name: archive.name ?? null,
154
160
  z: Number.isInteger(z) ? z : null,
@@ -309,6 +309,7 @@
309
309
  <nav>
310
310
  <button id="tab-archives" class="on">Archives</button>
311
311
  <button id="tab-categories">Categories</button>
312
+ <button id="tab-traffic">Traffic</button>
312
313
  <button id="tab-settings">Settings</button>
313
314
  <button id="logout" hidden>Sign out</button>
314
315
  </nav>
@@ -366,6 +367,17 @@
366
367
  </div>
367
368
  </section>
368
369
 
370
+ <section id="view-traffic" hidden>
371
+ <div class="bar">
372
+ <button id="traffic-refresh">Refresh</button>
373
+ <label class="choice">
374
+ <input type="checkbox" id="traffic-live" checked /> keep refreshing
375
+ </label>
376
+ <button id="traffic-reset" style="margin-left:auto">Reset counters</button>
377
+ </div>
378
+ <div id="traffic-body"><div class="sub">loading…</div></div>
379
+ </section>
380
+
369
381
  <section id="view-settings" hidden>
370
382
  <div class="bar">
371
383
  <button id="settings-save">Save changes</button>
@@ -3888,12 +3900,13 @@
3888
3900
 
3889
3901
  // ── Tabs ──────────────────────────────────────────────────────────────
3890
3902
  const showTab = (name) => {
3891
- for (const tab of ['archives', 'categories', 'settings']) {
3903
+ for (const tab of ['archives', 'categories', 'traffic', 'settings']) {
3892
3904
  $(`view-${tab}`).hidden = tab !== name;
3893
3905
  $(`tab-${tab}`).classList.toggle('on', tab === name);
3894
3906
  }
3895
3907
  if (name === 'settings') loadSettings().catch((e) => toast(e.message));
3896
3908
  if (name === 'categories') loadCategories().catch((e) => toast(e.message));
3909
+ if (name === 'traffic') loadTraffic().catch((e) => toast(e.message));
3897
3910
  };
3898
3911
  // The footer's year, set from the clock rather than typed into a file
3899
3912
  // nobody will remember to edit. The version beside it arrives with the
@@ -3902,6 +3915,157 @@
3902
3915
 
3903
3916
  $('tab-archives').onclick = () => showTab('archives');
3904
3917
  $('tab-categories').onclick = () => showTab('categories');
3918
+ $('tab-traffic').onclick = () => showTab('traffic');
3919
+
3920
+ // ── Traffic ───────────────────────────────────────────────────────────
3921
+ //
3922
+ // What this node has served, which is a different question from whether
3923
+ // it is healthy. Behind a balancer it is the only honest way to see how
3924
+ // traffic is really distributed: ask each node what it did, rather than
3925
+ // trusting what the balancer believes it sent.
3926
+ let trafficTimer = null;
3927
+
3928
+ /** Draws a zoom histogram, since zoom is the shape of the load. */
3929
+ const zoomBars = (counts) => {
3930
+ const entries = Object.entries(counts ?? {});
3931
+ if (entries.length === 0) return '<span class="sub">β€”</span>';
3932
+ const peak = Math.max(...entries.map(([, n]) => n));
3933
+ return entries
3934
+ .map(([z, n]) => {
3935
+ const height = Math.max(2, Math.round((n / peak) * 20));
3936
+ return `<span title="z${z}: ${n.toLocaleString()}" style="display:inline-block;width:6px;margin-right:1px;vertical-align:bottom;height:${height}px;background:var(--accent);opacity:0.75"></span>`;
3937
+ })
3938
+ .join('');
3939
+ };
3940
+
3941
+ const statusList = (counts) => {
3942
+ const entries = Object.entries(counts ?? {});
3943
+ if (entries.length === 0) return '<span class="sub">β€”</span>';
3944
+ return entries.map(([code, n]) => `${code} ${n.toLocaleString()}`).join(' Β· ');
3945
+ };
3946
+
3947
+ /**
3948
+ * Reads the report and draws it.
3949
+ * @returns {Promise<void>} - Resolves once drawn.
3950
+ */
3951
+ async function loadTraffic() {
3952
+ const body = $('traffic-body');
3953
+ let report;
3954
+ try {
3955
+ report = await api('/api/stats?recent=40');
3956
+ } catch (error) {
3957
+ if (error.status === 501) {
3958
+ body.innerHTML =
3959
+ '<div class="panel"><div class="sub">Tile statistics are turned off on this node β€” set <code>tileStats</code> in the configuration to enable them.</div></div>';
3960
+ return;
3961
+ }
3962
+ throw error;
3963
+ }
3964
+
3965
+ const archives = Object.entries(report.archives ?? {}).sort(
3966
+ (a, b) => b[1].requests - a[1].requests,
3967
+ );
3968
+ const since = new Date(report.since);
3969
+ const recent = report.recent ?? [];
3970
+
3971
+ const archiveRows = archives
3972
+ .map(([hash, a]) => {
3973
+ const clients = Object.keys(a.clients ?? {}).length;
3974
+ return `<tr>
3975
+ <td><b>${escapeHtml(a.name ?? hash.slice(0, 12))}</b>
3976
+ <div class="sub">${escapeHtml(hash.slice(0, 12))}…${clients ? ` Β· ${clients} client${clients === 1 ? '' : 's'}` : ''}</div></td>
3977
+ <td>${a.requests.toLocaleString()}</td>
3978
+ <td>${bytes(a.bytes)}</td>
3979
+ <td>${zoomBars(a.byZoom)}</td>
3980
+ <td class="sub">${statusList(a.byStatus)}</td>
3981
+ <td>${a.p50ms === null ? 'β€”' : `${Math.round(a.p50ms)} / ${Math.round(a.p95ms)} ms`}</td>
3982
+ </tr>`;
3983
+ })
3984
+ .join('');
3985
+
3986
+ const recentRows = recent
3987
+ .map(
3988
+ (row) => `<tr>
3989
+ <td class="sub">${new Date(row.at).toLocaleTimeString()}</td>
3990
+ <td>${escapeHtml(row.ip ?? 'β€”')}</td>
3991
+ <td class="sub">${escapeHtml(row.name ?? (row.infoHash ?? '').slice(0, 12) ?? 'β€”')}</td>
3992
+ <td>${row.z ?? 'β€”'}/${row.x ?? 'β€”'}/${row.y ?? 'β€”'}</td>
3993
+ <td>${row.status ?? 'β€”'}</td>
3994
+ <td>${bytes(row.bytes)}</td>
3995
+ <td>${row.ms === null ? 'β€”' : `${Math.round(row.ms)} ms`}</td>
3996
+ </tr>`,
3997
+ )
3998
+ .join('');
3999
+
4000
+ body.innerHTML = `
4001
+ <div class="panel" style="margin-bottom:0.8rem">
4002
+ <h2 style="margin-top:0">${escapeHtml(report.node ?? 'this node')}</h2>
4003
+ <div class="grid">
4004
+ <div><span>requests</span><b>${(report.requests ?? 0).toLocaleString()}</b></div>
4005
+ <div><span>sent</span><b>${bytes(report.bytes)}</b></div>
4006
+ <div><span>archives touched</span><b>${archives.length}</b></div>
4007
+ <div><span>counting since</span><b>${since.toLocaleTimeString()}</b>
4008
+ <span class="sub">a restart clears this</span></div>
4009
+ </div>
4010
+ </div>
4011
+
4012
+ ${
4013
+ archives.length === 0
4014
+ ? '<div class="panel"><div class="sub">Nothing served yet. Counters start empty and a restart clears them.</div></div>'
4015
+ : `<div class="panel" style="margin-bottom:0.8rem">
4016
+ <h2 style="margin-top:0">By archive</h2>
4017
+ <table><thead><tr>
4018
+ <th>Archive</th><th>Requests</th><th>Sent</th><th>Zooms</th><th>Status</th><th>p50 / p95</th>
4019
+ </tr></thead><tbody>${archiveRows}</tbody></table>
4020
+ <div class="sub" style="margin-top:0.5rem">
4021
+ Bytes are counted as sent, so a gzipped vector tile counts compressed β€” the
4022
+ number that matters for bandwidth, and not what the client ends up holding.
4023
+ </div>
4024
+ </div>`
4025
+ }
4026
+
4027
+ ${
4028
+ recent.length === 0
4029
+ ? ''
4030
+ : `<div class="panel">
4031
+ <h2 style="margin-top:0">Recent requests</h2>
4032
+ <table><thead><tr>
4033
+ <th>When</th><th>From</th><th>Archive</th><th>Tile</th><th>Status</th><th>Size</th><th>Took</th>
4034
+ </tr></thead><tbody>${recentRows}</tbody></table>
4035
+ <div class="sub" style="margin-top:0.5rem">
4036
+ The address is whatever this process can see. Behind a proxy sending no
4037
+ <code>X-Forwarded-For</code> it is the proxy for everything arriving through
4038
+ it β€” which still separates direct traffic from proxied, but not who sent it.
4039
+ </div>
4040
+ </div>`
4041
+ }`;
4042
+ }
4043
+
4044
+ const trafficPoll = () => {
4045
+ clearInterval(trafficTimer);
4046
+ if (!$('traffic-live').checked) return;
4047
+ trafficTimer = setInterval(() => {
4048
+ if (!$('view-traffic').hidden) loadTraffic().catch(() => {});
4049
+ }, 5000);
4050
+ };
4051
+
4052
+ $('traffic-refresh').onclick = () => loadTraffic().catch((e) => toast(e.message));
4053
+ $('traffic-live').onchange = trafficPoll;
4054
+ trafficPoll();
4055
+
4056
+ $('traffic-reset').onclick = async () => {
4057
+ // Deliberately separate from reading: a page polling the endpoint must
4058
+ // never be able to erase the history it is drawing.
4059
+ if (!confirm('Clear the counters and recent requests on this node?')) return;
4060
+ try {
4061
+ await api('/api/stats', { method: 'DELETE' });
4062
+ toast('Counters cleared');
4063
+ await loadTraffic();
4064
+ } catch (error) {
4065
+ toast(error.message);
4066
+ }
4067
+ };
4068
+
3905
4069
  $('tab-settings').onclick = () => showTab('settings');
3906
4070
 
3907
4071
  // ── Sign in ───────────────────────────────────────────────────────────