pmtiles-swarm 0.26.0 → 0.27.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,76 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.27.0
11
+ ### ✨ Features and improvements
12
+ - **Bandwidth history per archive, kept across restarts.** The tile side of this question already
13
+
14
+ The Traffic tab draws it: one chart for the node with a window of an hour, a day or a week, and
15
+ a table of what each archive moved over that window. Drawn as inline SVG rather than through a
16
+ charting library — the console is a single self-contained file the node serves itself, and a
17
+ dependency for two lines would have to be vendored, kept current, and shipped on every page load
18
+ for a panel most visits never open. Both lines share one scale, because drawing each against its
19
+ own maximum renders a node uploading 2 KB/s and downloading 200 MB/s as two similar lines, which
20
+ is the opposite of what a chart is for.
21
+ had an answer; the swarm side had none. An archive could seed steadily for a day and leave no
22
+ trace but a speed in the console that is gone the moment you look away, which makes "what is
23
+ using the bandwidth" and "is this archive earning its disk" unanswerable.
24
+
25
+ Upload and download speed are now sampled per archive on a timer and kept in `stats.db`, beside
26
+ the catalog in `dataDir` — not beside the config, which is the operator's: hand-edited, diffed,
27
+ copied between nodes, and the thing you reach for when a node will not start. A database that
28
+ grows on its own does not belong there. `node:sqlite` is built in and already used for MBTiles,
29
+ so this costs no dependency, and it is imported lazily for the same reason `mbtiles.js` does it:
30
+ the first require prints an experimental warning nobody with this switched off should have to
31
+ explain.
32
+
33
+ Persisted rather than held in memory, unlike `tileStats`, because it answers a question about
34
+ the past — restarting to pick up a new version would erase exactly the week somebody wanted to
35
+ look at. Application logs stay in the journal; this is only for numbers that have to survive a
36
+ restart.
37
+
38
+ Two settings, because they are two questions: `traffic.sampleSeconds` is how finely it looks and
39
+ `traffic.keepHours` how far back it remembers, defaulting to every 15 seconds for a week. Read
40
+ back through `GET /api/traffic`, averaged into buckets so a week of samples is a graph rather
41
+ than forty thousand points, with `totals` ranking archives by bytes moved. `traffic: false`
42
+ turns the whole thing off, and a database that cannot be opened is reported rather than fatal —
43
+ a node that cannot record what it moved should still move it.
44
+
45
+ ### 🐞 Bug fixes
46
+
47
+ ## 0.26.2
48
+ ### 🐞 Bug fixes
49
+ - **The public page's sort left the categories alone.** `apply()` sorted the archives and handed
50
+ the categories straight to the renderer, which does not sort either — so on a node carrying
51
+ twenty categories and a few archives the control looked completely dead. Categories are rendered
52
+ first and are the list worth reading, since they follow the newest build, so sorting only the
53
+ other list amounted to not sorting at all for most visitors.
54
+
55
+ All three orderings now apply to both, against the fields a category actually keeps: its own
56
+ name, and the date and size of the build it points at. The existing test could not have caught
57
+ this — it checked that every option in the select had a comparator behind it, which was true, and
58
+ said nothing about whether the categories were ever handed to one. The comparators are now lifted
59
+ out of the page and called directly.
60
+
61
+ ## 0.26.1
62
+ ### 🐞 Bug fixes
63
+ - **A download that finished was fetched all over again.** Resuming looked only at the
64
+ `.incomplete` path. A run that transferred the whole archive, had the marker removed, and then
65
+ stopped during the hashing left the file under its final name — so the restart found nothing to
66
+ resume and re-fetched every byte, with the finished copy sitting beside it the entire time.
67
+ Reported after 700 GB was transferred twice.
68
+
69
+ A file already under the final name is now checked against the length the source reports, and
70
+ hashed where it matches. Where it does not match it is not the archive being asked for, whatever
71
+ its name says, and the fetch proceeds — hashing it would publish the wrong bytes under the right
72
+ name, which is worse than transferring it again.
73
+ - **Hashing looked like a hang.** Progress was reported for the download and then nothing at all,
74
+ while `createTorrentFromFile` read the whole archive to build the piece hashes — twice, with
75
+ `md5` on. For a planet archive that is the longer half of the work and it was completely silent,
76
+ so a fetch reaching 100% and going quiet read as a stall. Reported as "it completed and never
77
+ started making the torrent", when it had been making it for some time. It now says what it is
78
+ doing when it starts, reports a heartbeat every minute while it runs, and says how long it took.
79
+
10
80
  ## 0.26.0
11
81
  ### 🐞 Bug fixes
12
82
  - **The add dialog stayed on screen for the length of a download.** `POST /api/torrents` awaited
package/README.md CHANGED
@@ -770,6 +770,7 @@ which the endpoint answers 501.
770
770
  | `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
771
771
  | `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
772
772
  | `GET` `DELETE` | `/api/stats` | What this node has served — per-archive counters and the last N requests; `DELETE` clears them |
773
+ | `GET` | `/api/traffic` | Upload and download speed per archive over time, from `stats.db`; `infoHash`, `hours` and `buckets` narrow it, and `totals` ranks archives by bytes moved |
773
774
  | `GET` `PATCH` | `/api/config` | Read and change settings |
774
775
  | `POST` | `/api/login`, `/api/logout` | Console sign-in |
775
776
  | `GET` | `/api/session` | Who this request is, and whether a credential is needed at all |
@@ -354,6 +354,62 @@ What the running service actually has:
354
354
  systemctl show -p ReadWritePaths -p UMask -p SupplementaryGroups pmtiles-swarm
355
355
  ```
356
356
 
357
+ ## The statistics database
358
+
359
+ Upload and download speed per archive is sampled on a timer and kept in
360
+ `stats.db`, in `dataDir` beside the catalog. It is on by default.
361
+
362
+ **It needs no new path.** `dataDir` is already in `ReadWritePaths` — the
363
+ catalog is written there on every change — so a unit that works today works
364
+ with this. Worth knowing rather than checking: the file appears on its own the
365
+ first time the node starts, with no migration step and nothing to create by
366
+ hand.
367
+
368
+ SQLite writes a journal beside the database while a transaction is open, so it
369
+ needs the **directory** writable and not only the file. That is the same
370
+ requirement the catalog already has, and the same failure if it is missing —
371
+ see [Two checks that lie](#two-checks-that-lie), which applies here unchanged.
372
+
373
+ ### The warning in the journal is expected
374
+
375
+ ```
376
+ ExperimentalWarning: SQLite is an experimental feature and might change at any time
377
+ ```
378
+
379
+ `node:sqlite` prints this the first time it is loaded. It is Node's warning
380
+ about its own module, not a statement about this node's data, and it appears
381
+ once at startup. It is mentioned here because a new warning in the journal
382
+ immediately after an upgrade is exactly the shape of a real fault, and it is
383
+ worth being able to dismiss it without an investigation.
384
+
385
+ ### How large it gets
386
+
387
+ Bounded by the retention window, not by how long the node has been running:
388
+
389
+ ```
390
+ rows = archives x keepHours x 3600 / sampleSeconds
391
+ ```
392
+
393
+ At the defaults — every 15 seconds, kept for a week — that is about 40,000 rows
394
+ per archive, so a node carrying twenty is a few megabytes. Samples past the
395
+ window are deleted every ten minutes.
396
+
397
+ SQLite does not return freed pages to the filesystem on its own, so the file
398
+ settles at roughly its high-water mark rather than shrinking after a large
399
+ retention cut. `VACUUM` reclaims it if that ever matters; at these sizes it
400
+ will not.
401
+
402
+ ### Turning it down, or off
403
+
404
+ ```json
405
+ { "traffic": { "sampleSeconds": 60, "keepHours": 24 } }
406
+ ```
407
+
408
+ Both are reloaded without a restart. `"traffic": false` switches it off
409
+ entirely, and a database that cannot be opened is reported and stepped over
410
+ rather than being fatal — a node that cannot record what it moved should still
411
+ move it.
412
+
357
413
  ## The publisher key
358
414
 
359
415
  Only needed if this node publishes BEP 46 records — the signed DHT entries that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.26.0",
3
+ "version": "0.27.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
@@ -105,6 +105,7 @@ export function createApp({
105
105
  config,
106
106
  speed,
107
107
  stats,
108
+ traffic,
108
109
  reloaders = {},
109
110
  shutdown,
110
111
  }) {
@@ -556,6 +557,33 @@ export function createApp({
556
557
  // is healthy. Admin-side: it names archives and client addresses, and a
557
558
  // public endpoint reporting who else is using a node is a privacy question
558
559
  // nobody asked for.
560
+ // What the swarm moved, over time. The tile side of this is /api/stats;
561
+ // this is the half that was invisible -- an archive could seed steadily for
562
+ // a day and leave no trace but a speed that vanishes when you look away.
563
+ app.get(
564
+ '/api/traffic',
565
+ route(async (req, res) => {
566
+ if (!traffic) {
567
+ return res
568
+ .status(501)
569
+ .json({ error: 'traffic statistics are disabled' });
570
+ }
571
+ const number = (value) =>
572
+ value === undefined ? undefined : Number(value);
573
+ const hours = number(req.query.hours);
574
+ const buckets = number(req.query.buckets);
575
+ res.json({
576
+ sampleSeconds: traffic.sampleSeconds,
577
+ keepHours: traffic.keepHours,
578
+ // One archive when asked for, every archive summed when not.
579
+ ...traffic.series({ infoHash: req.query.infoHash, hours, buckets }),
580
+ // Beside the series rather than behind a second request: who used the
581
+ // bandwidth is asked at the same moment as how much of it there was.
582
+ totals: traffic.totals({ hours }),
583
+ });
584
+ }),
585
+ );
586
+
559
587
  app.get(
560
588
  '/api/stats',
561
589
  route(async (req, res) => {
package/src/config.js CHANGED
@@ -216,6 +216,25 @@ const DEFAULTS = {
216
216
  /** Recent requests kept for inspection. Zero keeps only the counters. */
217
217
  recent: 200,
218
218
  },
219
+ /**
220
+ * Upload and download speed per archive, sampled on a timer and kept in
221
+ * `stats.db` beside the catalog. `false` turns it off.
222
+ *
223
+ * Persisted rather than held in memory, unlike tileStats, because it answers
224
+ * a question about the past: restarting to pick up a new version would erase
225
+ * exactly the week somebody wanted to look at. Application logs stay in the
226
+ * journal -- this is only for numbers that have to survive a restart.
227
+ *
228
+ * Two settings because they are two questions: how finely it looks, and how
229
+ * far back it remembers. Sampling every 15 seconds for a week is roughly
230
+ * 40,000 rows per archive, a few megabytes for a node carrying twenty.
231
+ */
232
+ traffic: {
233
+ /** Seconds between samples. */
234
+ sampleSeconds: 15,
235
+ /** How far back to keep them, in hours. */
236
+ keepHours: 168,
237
+ },
219
238
  /**
220
239
  * Tile serving: a TileJSON endpoint and z/x/y tiles per archive. A node
221
240
  * holding a complete copy reads its local file; one in cache mode reads
@@ -583,6 +602,7 @@ export const RELOADABLE = new Map([
583
602
  // Read when something is added rather than held open, so a change applies to
584
603
  // the next add with nothing to restart.
585
604
  ['locations', 'none'],
605
+ ['traffic', 'traffic'],
586
606
  ['watch', 'watchers'],
587
607
  ['onAdded', 'hooks'],
588
608
  ['onComplete', 'hooks'],
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import { closeServer, installSignalHandlers, runStoppers } from './shutdown.js';
20
20
  import { ScheduledSourceManager } from './sources.js';
21
21
  import { SubscriptionManager } from './subscriptions.js';
22
22
  import { TileStats } from './tile-stats.js';
23
+ import { TrafficStats, openStatsDatabase } from './traffic-stats.js';
23
24
  import { TileStore } from './tiles.js';
24
25
  import { HeadWarmer } from './prewarm.js';
25
26
  import { WarmRunner } from './warm.js';
@@ -271,6 +272,29 @@ PMTILES_SWARM_PUBLIC_URL
271
272
  ? null
272
273
  : new TileStats({ recent: config.tileStats?.recent });
273
274
 
275
+ // What the swarm moved, as opposed to what the tile endpoint served.
276
+ // Persisted, because it answers a question about the past: restarting to
277
+ // pick up a new version would erase the week somebody wanted to look at.
278
+ let traffic = null;
279
+ if (config.traffic !== false) {
280
+ try {
281
+ traffic = new TrafficStats({
282
+ db: await openStatsDatabase(config),
283
+ engine,
284
+ config,
285
+ });
286
+ traffic.start();
287
+ console.log(
288
+ `[traffic] sampling every ${traffic.sampleSeconds}s, keeping ` +
289
+ `${traffic.keepHours}h`,
290
+ );
291
+ } catch (error) {
292
+ // Never fatal. A node that cannot record what it moved should still
293
+ // move it, and the alternative is refusing to start over a graph.
294
+ console.error(`[traffic] not recording: ${error.message}`);
295
+ }
296
+ }
297
+
274
298
  // Announcing the current build of each category over the DHT. Only ever on
275
299
  // the node that builds: the key signs what subscribers believe is current,
276
300
  // and two publishers under one key would fight over the sequence number.
@@ -364,6 +388,13 @@ PMTILES_SWARM_PUBLIC_URL
364
388
  watch.stop();
365
389
  watch.start(config.watch);
366
390
  },
391
+ traffic: () => {
392
+ // Rebuilt rather than restarted: sampleSeconds and keepHours are both
393
+ // read when the sampler is constructed, so this is the whole of what a
394
+ // restart would have achieved.
395
+ traffic?.stop();
396
+ traffic?.start();
397
+ },
367
398
  hooks: () => {
368
399
  hooks.stop();
369
400
  hooks.start();
@@ -402,6 +433,7 @@ PMTILES_SWARM_PUBLIC_URL
402
433
  config,
403
434
  speed,
404
435
  stats,
436
+ traffic,
405
437
  reloaders,
406
438
  shutdown: () => runStoppers(stoppers),
407
439
  });
@@ -537,6 +569,7 @@ PMTILES_SWARM_PUBLIC_URL
537
569
  subscriptions.stop();
538
570
  warm.stop();
539
571
  headWarmer.stop();
572
+ traffic?.stop();
540
573
  },
541
574
  ms: 1000,
542
575
  },
@@ -49,13 +49,48 @@ export async function createTorrentFromFile(filePath, options = {}) {
49
49
  if (!stat.isFile() || stat.size === 0) {
50
50
  throw new Error(`not a usable file: ${filePath}`);
51
51
  }
52
- // A second read of the archive, which is why it is opt-in. Nothing else here
53
- // touches these bytes again once the piece hashes are done.
54
- const md5Digest = options.md5 ? await md5File(filePath) : undefined;
55
- return buildTorrent(filePath, path.basename(filePath), stat.size, {
56
- ...options,
57
- md5Digest,
52
+
53
+ // Says that it started, and keeps saying it is going.
54
+ //
55
+ // Hashing reads the whole archive, and with md5 on it reads it twice — for a
56
+ // planet archive that is the longer half of the job, and it emitted nothing
57
+ // at all. The download reports every second and then stops dead, so the
58
+ // symptom is a fetch that reaches 100% and appears to hang; reported from the
59
+ // field as "it completed and never started making the torrent", when it had
60
+ // in fact been making it for some time.
61
+ const what = options.sourceUrl ?? filePath;
62
+ const passes = options.md5 ? 'twice, once of them for the MD5' : 'once';
63
+ console.log(
64
+ `[hash] ${what}: reading ${stat.size} bytes ${passes} to build the torrent`,
65
+ );
66
+ const startedAt = Date.now();
67
+ const heartbeat = setInterval(() => {
68
+ const minutes = Math.round((Date.now() - startedAt) / 60000);
69
+ console.log(`[hash] ${what}: still hashing after ${minutes}m`);
70
+ }, 60000);
71
+ heartbeat.unref?.();
72
+ options.onProgress?.({
73
+ phase: 'hashing',
74
+ received: stat.size,
75
+ total: stat.size,
58
76
  });
77
+
78
+ try {
79
+ // A second read of the archive, which is why it is opt-in. Nothing else
80
+ // here touches these bytes again once the piece hashes are done.
81
+ const md5Digest = options.md5 ? await md5File(filePath) : undefined;
82
+ const built = await buildTorrent(
83
+ filePath,
84
+ path.basename(filePath),
85
+ stat.size,
86
+ { ...options, md5Digest },
87
+ );
88
+ const seconds = Math.round((Date.now() - startedAt) / 1000);
89
+ console.log(`[hash] ${what}: torrent built in ${seconds}s`);
90
+ return built;
91
+ } finally {
92
+ clearInterval(heartbeat);
93
+ }
59
94
  }
60
95
 
61
96
  /**
@@ -91,21 +126,53 @@ export async function createTorrentFromUrl(url, options = {}) {
91
126
  // verify. The marker means the URL 404s until the moment it is real.
92
127
  const target = path.join(options.retainPath, name);
93
128
  const marker = options.incompleteSuffix ?? DEFAULT_SUFFIX;
94
- await downloadTo(
95
- url,
96
- `${target}${marker}`,
97
- options.onProgress,
98
- options.signal,
99
- {
129
+ const partial = `${target}${marker}`;
130
+
131
+ // A run that got all the way through the download and stopped during the
132
+ // hashing leaves the archive under its final name, the marker already
133
+ // removed. Resuming looked only at the marker path, so it saw nothing to
134
+ // continue, and fetched the whole thing again with the finished copy
135
+ // sitting beside it -- 700 GB re-transferred to arrive at a file that was
136
+ // already there.
137
+ //
138
+ // Hashing is the longer half for a large archive and reports nothing while
139
+ // it runs, so being interrupted in it is not unlikely.
140
+ const finished = await bytesOnDisk(target);
141
+ const resuming = await bytesOnDisk(partial);
142
+ let haveIt = false;
143
+ if (finished > 0 && resuming === 0) {
144
+ const expected = await remoteLength(url, options.signal);
145
+ if (expected && finished === expected) {
146
+ haveIt = true;
147
+ console.log(
148
+ `[fetch] ${url} is already downloaded (${finished} bytes); ` +
149
+ 'hashing what is on disk rather than fetching it again',
150
+ );
151
+ } else {
152
+ // Same name, different length: whatever this is, it is not the archive
153
+ // being asked for, and hashing it would publish the wrong bytes under
154
+ // the right name. The download proceeds to the marker path as usual.
155
+ console.warn(
156
+ `[fetch] ${target} exists but is ${finished} bytes against ` +
157
+ `${expected ?? 'an unknown length'} at the source; fetching again`,
158
+ );
159
+ }
160
+ }
161
+
162
+ if (!haveIt) {
163
+ await downloadTo(url, partial, options.onProgress, options.signal, {
100
164
  attempts: options.fetchAttempts,
101
165
  retryDelayMs: options.fetchRetryDelayMs,
102
- },
103
- );
104
- if (marker) await fs.rename(`${target}${marker}`, target);
166
+ });
167
+ if (marker) await fs.rename(partial, target);
168
+ }
169
+
105
170
  const created = await createTorrentFromFile(target, {
106
171
  ...options,
107
172
  name,
108
173
  webSeeds,
174
+ onProgress: options.onProgress,
175
+ sourceUrl: url,
109
176
  });
110
177
  return { ...created, retainedAt: target };
111
178
  }
@@ -157,6 +224,30 @@ export async function createTorrentFromUrl(url, options = {}) {
157
224
  * @param {string} target - The partial file.
158
225
  * @returns {Promise<number>} - Bytes on disk.
159
226
  */
227
+ /**
228
+ * What the source says the archive is, in bytes, or 0 when it will not say.
229
+ *
230
+ * Used to decide whether a file already under its final name is the archive
231
+ * being asked for. A HEAD is enough and costs nothing next to the alternative,
232
+ * which is transferring the whole thing a second time to find out.
233
+ *
234
+ * @param {string} url - The archive's URL.
235
+ * @param {AbortSignal} [signal] - Cancels the probe.
236
+ * @returns {Promise<number>} - Length, or 0.
237
+ */
238
+ async function remoteLength(url, signal) {
239
+ try {
240
+ const response = await fetch(url, { method: 'HEAD', signal });
241
+ if (!response.ok) return 0;
242
+ return Number(response.headers.get('content-length') ?? 0) || 0;
243
+ } catch {
244
+ // A source that refuses HEAD tells us nothing, which is not the same as
245
+ // telling us the file is wrong. The caller re-fetches, which is what it
246
+ // would have done anyway.
247
+ return 0;
248
+ }
249
+ }
250
+
160
251
  async function bytesOnDisk(target) {
161
252
  const stat = await fs.stat(target).catch(() => null);
162
253
  return stat?.isFile() ? stat.size : 0;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * What each archive has been sending and receiving, over time.
3
+ *
4
+ * The tile side of this question already has an answer: TileStats records what
5
+ * this node served over HTTP. The swarm side had none. An archive could be
6
+ * uploading steadily for a day and the only evidence was a speed in the console
7
+ * that is gone the moment you look away — which makes "what is actually using
8
+ * the bandwidth" and "is this archive earning its disk" unanswerable.
9
+ *
10
+ * Kept in SQLite rather than in memory, unlike TileStats, and the difference is
11
+ * deliberate. Tile counters answer a question about now and are cheap to
12
+ * rebuild by waiting; a bandwidth history answers a question about the past and
13
+ * cannot be rebuilt at all — restarting to pick up a new version would erase
14
+ * exactly the week somebody wanted to look at. `node:sqlite` is built in and
15
+ * already used for MBTiles, so this costs no dependency.
16
+ *
17
+ * Two knobs, because they are two questions. `sampleSeconds` is how finely it
18
+ * looks; `keepHours` is how far back it remembers. Sampling every 15 seconds
19
+ * for a week is roughly 40,000 rows per archive — a few megabytes for a node
20
+ * carrying twenty, which is worth it for being able to answer at all.
21
+ */
22
+
23
+ import path from 'node:path';
24
+
25
+ /** How often to take a sample, when the config says nothing. */
26
+ const DEFAULT_SAMPLE_SECONDS = 15;
27
+
28
+ /** How far back to keep samples, when the config says nothing. */
29
+ const DEFAULT_KEEP_HOURS = 168;
30
+
31
+ /** How often to delete samples that have aged out. */
32
+ const PRUNE_EVERY_MS = 10 * 60 * 1000;
33
+
34
+ /**
35
+ * Rounds a retention window into a sensible number of buckets for a graph.
36
+ *
37
+ * A week of 15-second samples is 40,000 points and a chart a thousand pixels
38
+ * wide; sending all of them wastes the transfer and the browser's time to draw
39
+ * something no eye can resolve. Averaging into buckets keeps the shape.
40
+ *
41
+ * @param {number} from - Start of the window, unix seconds.
42
+ * @param {number} to - End of the window, unix seconds.
43
+ * @param {number} buckets - How many points are wanted.
44
+ * @returns {number} - Seconds per bucket, never less than one.
45
+ */
46
+ export function bucketSeconds(from, to, buckets) {
47
+ const span = Math.max(1, to - from);
48
+ return Math.max(1, Math.round(span / Math.max(1, buckets)));
49
+ }
50
+
51
+ /** Per-archive upload and download speed, sampled on a timer and retained. */
52
+ export class TrafficStats {
53
+ #db;
54
+ #insert;
55
+ #timer;
56
+ #pruneTimer;
57
+ #engine;
58
+ #now;
59
+ #sampleMs;
60
+ #keepSeconds;
61
+
62
+ /**
63
+ * @param {object} options - Options.
64
+ * @param {object} options.db - An open node:sqlite DatabaseSync.
65
+ * @param {object} options.engine - The engine to sample.
66
+ * @param {object} [options.config] - Resolved configuration.
67
+ * @param {Function} [options.now] - Clock returning unix seconds, for tests.
68
+ */
69
+ constructor({
70
+ db,
71
+ engine,
72
+ config = {},
73
+ now = () => Math.floor(Date.now() / 1000),
74
+ }) {
75
+ this.#db = db;
76
+ this.#engine = engine;
77
+ this.#now = now;
78
+
79
+ const sample = Number(config.traffic?.sampleSeconds);
80
+ this.#sampleMs =
81
+ (Number.isFinite(sample) && sample > 0
82
+ ? sample
83
+ : DEFAULT_SAMPLE_SECONDS) * 1000;
84
+ const keep = Number(config.traffic?.keepHours);
85
+ this.#keepSeconds =
86
+ (Number.isFinite(keep) && keep > 0 ? keep : DEFAULT_KEEP_HOURS) * 3600;
87
+
88
+ this.#db.exec(`
89
+ CREATE TABLE IF NOT EXISTS traffic (
90
+ info_hash TEXT NOT NULL,
91
+ at INTEGER NOT NULL,
92
+ down INTEGER NOT NULL,
93
+ up INTEGER NOT NULL
94
+ );
95
+ CREATE INDEX IF NOT EXISTS traffic_at ON traffic (at);
96
+ CREATE INDEX IF NOT EXISTS traffic_hash_at ON traffic (info_hash, at);
97
+ `);
98
+ this.#insert = this.#db.prepare(
99
+ 'INSERT INTO traffic (info_hash, at, down, up) VALUES (?, ?, ?, ?)',
100
+ );
101
+ }
102
+
103
+ /** @returns {number} - Seconds between samples. */
104
+ get sampleSeconds() {
105
+ return this.#sampleMs / 1000;
106
+ }
107
+
108
+ /** @returns {number} - How far back samples are kept, in hours. */
109
+ get keepHours() {
110
+ return this.#keepSeconds / 3600;
111
+ }
112
+
113
+ /**
114
+ * Takes one sample of every torrent the engine knows about.
115
+ *
116
+ * Zero rows are written as readily as busy ones: a gap in the series would
117
+ * be indistinguishable from the node having been switched off, and "this
118
+ * archive did nothing all week" is a real answer somebody wants.
119
+ *
120
+ * @returns {Promise<number>} - How many rows were written.
121
+ */
122
+ async sample() {
123
+ let torrents;
124
+ try {
125
+ torrents = await this.#engine.list();
126
+ } catch (error) {
127
+ // A sampler that throws takes its timer down with it and the history
128
+ // simply stops, which is the one failure that cannot be noticed later.
129
+ console.warn(`[traffic] could not sample: ${error.message}`);
130
+ return 0;
131
+ }
132
+
133
+ const at = this.#now();
134
+ let written = 0;
135
+ for (const torrent of torrents ?? []) {
136
+ if (!torrent?.infoHash) continue;
137
+ this.#insert.run(
138
+ String(torrent.infoHash).toLowerCase(),
139
+ at,
140
+ Math.max(0, Math.round(torrent.downloadSpeed ?? 0)),
141
+ Math.max(0, Math.round(torrent.uploadSpeed ?? 0)),
142
+ );
143
+ written += 1;
144
+ }
145
+ return written;
146
+ }
147
+
148
+ /**
149
+ * Deletes samples that have aged past the retention window.
150
+ * @returns {number} - Rows removed.
151
+ */
152
+ prune() {
153
+ const cutoff = this.#now() - this.#keepSeconds;
154
+ const result = this.#db
155
+ .prepare('DELETE FROM traffic WHERE at < ?')
156
+ .run(cutoff);
157
+ return Number(result?.changes ?? 0);
158
+ }
159
+
160
+ /**
161
+ * The series for one archive, or for every archive summed.
162
+ *
163
+ * Averaged into buckets rather than returned raw — see bucketSeconds. The
164
+ * timestamp of a bucket is its start, so a caller plotting them gets evenly
165
+ * spaced points without having to know the sample interval.
166
+ *
167
+ * @param {object} [options] - Options.
168
+ * @param {string} [options.infoHash] - One archive, or every one summed.
169
+ * @param {number} [options.hours] - How far back to read.
170
+ * @param {number} [options.buckets] - How many points to return.
171
+ * @returns {object} - `{from, to, seconds, points}`.
172
+ */
173
+ series({ infoHash, hours, buckets = 240 } = {}) {
174
+ const to = this.#now();
175
+ const span =
176
+ Number.isFinite(hours) && hours > 0 ? hours * 3600 : this.#keepSeconds;
177
+ const from = to - span;
178
+ const seconds = bucketSeconds(from, to, buckets);
179
+
180
+ // Floored with a modulo rather than by dividing and multiplying back:
181
+ // node:sqlite binds a JS number as REAL, so `at / 900` is float division
182
+ // and every row lands in a bucket of its own -- a week of samples came
183
+ // back as a week of samples. SQLite's `%` casts to integer, which is
184
+ // exactly the flooring wanted.
185
+ //
186
+ // Summed across archives inside a bucket and then averaged over the
187
+ // buckets' samples: at one moment the node's throughput is the sum of what
188
+ // every torrent is doing, and over time it is the mean of those moments.
189
+ const rows = infoHash
190
+ ? this.#db
191
+ .prepare(
192
+ `SELECT at - (at % ?) AS bucket,
193
+ AVG(down) AS down, AVG(up) AS up
194
+ FROM traffic
195
+ WHERE info_hash = ? AND at >= ?
196
+ GROUP BY bucket ORDER BY bucket`,
197
+ )
198
+ .all(seconds, String(infoHash).toLowerCase(), from)
199
+ : this.#db
200
+ .prepare(
201
+ `SELECT bucket, AVG(down) AS down, AVG(up) AS up FROM (
202
+ SELECT at - (at % ?) AS bucket, at,
203
+ SUM(down) AS down, SUM(up) AS up
204
+ FROM traffic WHERE at >= ?
205
+ GROUP BY at
206
+ ) GROUP BY bucket ORDER BY bucket`,
207
+ )
208
+ .all(seconds, from);
209
+
210
+ return {
211
+ from,
212
+ to,
213
+ seconds,
214
+ points: rows.map((row) => ({
215
+ at: Number(row.bucket),
216
+ down: Math.round(Number(row.down) || 0),
217
+ up: Math.round(Number(row.up) || 0),
218
+ })),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Totals per archive over a window, for ranking who used what.
224
+ *
225
+ * Speeds are a rate, so bytes are the rate multiplied by the interval it was
226
+ * sampled over. Approximate by construction -- it assumes each sample held
227
+ * until the next -- and the right shape of approximate: it cannot drift from
228
+ * the graph above it, because it is the same numbers.
229
+ *
230
+ * @param {object} [options] - Options.
231
+ * @param {number} [options.hours] - How far back to read.
232
+ * @returns {object[]} - `{infoHash, down, up, samples}`, busiest first.
233
+ */
234
+ totals({ hours } = {}) {
235
+ const span =
236
+ Number.isFinite(hours) && hours > 0 ? hours * 3600 : this.#keepSeconds;
237
+ const from = this.#now() - span;
238
+ const every = this.sampleSeconds;
239
+ return this.#db
240
+ .prepare(
241
+ `SELECT info_hash AS infoHash,
242
+ SUM(down) * ? AS down, SUM(up) * ? AS up,
243
+ COUNT(*) AS samples
244
+ FROM traffic WHERE at >= ?
245
+ GROUP BY info_hash
246
+ ORDER BY (SUM(down) + SUM(up)) DESC`,
247
+ )
248
+ .all(every, every, from)
249
+ .map((row) => ({
250
+ infoHash: row.infoHash,
251
+ down: Math.round(Number(row.down) || 0),
252
+ up: Math.round(Number(row.up) || 0),
253
+ samples: Number(row.samples) || 0,
254
+ }));
255
+ }
256
+
257
+ /** Starts sampling and pruning. @returns {void} */
258
+ start() {
259
+ if (this.#timer) return;
260
+ const tick = () =>
261
+ this.sample().catch((error) =>
262
+ console.warn(`[traffic] sample failed: ${error.message}`),
263
+ );
264
+ this.#timer = setInterval(tick, this.#sampleMs);
265
+ this.#timer.unref?.();
266
+
267
+ // Pruned on a slow timer of its own rather than after every sample: it is a
268
+ // whole-table delete and the sample runs every few seconds.
269
+ this.#pruneTimer = setInterval(() => {
270
+ try {
271
+ this.prune();
272
+ } catch (error) {
273
+ console.warn(`[traffic] could not prune: ${error.message}`);
274
+ }
275
+ }, PRUNE_EVERY_MS);
276
+ this.#pruneTimer.unref?.();
277
+ }
278
+
279
+ /** Stops sampling. @returns {void} */
280
+ stop() {
281
+ if (this.#timer) clearInterval(this.#timer);
282
+ if (this.#pruneTimer) clearInterval(this.#pruneTimer);
283
+ this.#timer = undefined;
284
+ this.#pruneTimer = undefined;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Opens the stats database, beside the catalog rather than beside the config.
290
+ *
291
+ * dataDir is where this node's own mutable state already lives -- the catalog,
292
+ * resume data, the DHT node cache. The config directory is the operator's:
293
+ * hand-edited, diffed, copied between nodes, and the thing you reach for when a
294
+ * node will not start. A database that grows on its own does not belong there.
295
+ *
296
+ * @param {object} config - Resolved configuration.
297
+ * @returns {Promise<object>} - An open DatabaseSync.
298
+ */
299
+ export async function openStatsDatabase(config) {
300
+ // Imported here rather than at module load because node:sqlite prints an
301
+ // ExperimentalWarning the first time it is required, and a node with stats
302
+ // switched off should not be made to explain that warning. Same reasoning as
303
+ // mbtiles.js.
304
+ const { DatabaseSync } = await import('node:sqlite');
305
+ return new DatabaseSync(path.join(config.dataDir, 'stats.db'));
306
+ }
@@ -375,6 +375,18 @@
375
375
  </label>
376
376
  <button id="traffic-reset" style="margin-left:auto">Reset counters</button>
377
377
  </div>
378
+ <div class="bar">
379
+ <label>
380
+ Swarm bandwidth
381
+ <select id="traffic-window">
382
+ <option value="1">last hour</option>
383
+ <option value="24" selected>last 24 hours</option>
384
+ <option value="168">last week</option>
385
+ </select>
386
+ </label>
387
+ <span class="sub" id="traffic-window-note"></span>
388
+ </div>
389
+ <div id="swarm-traffic"><div class="sub">loading…</div></div>
378
390
  <div id="traffic-body"><div class="sub">loading…</div></div>
379
391
  </section>
380
392
 
@@ -3919,7 +3931,10 @@
3919
3931
  }
3920
3932
  if (name === 'settings') loadSettings().catch((e) => toast(e.message));
3921
3933
  if (name === 'categories') loadCategories().catch((e) => toast(e.message));
3922
- if (name === 'traffic') loadTraffic().catch((e) => toast(e.message));
3934
+ if (name === 'traffic') {
3935
+ loadTraffic().catch((e) => toast(e.message));
3936
+ loadSwarmTraffic();
3937
+ }
3923
3938
  };
3924
3939
  // The footer's year, set from the clock rather than typed into a file
3925
3940
  // nobody will remember to edit. The version beside it arrives with the
@@ -3961,6 +3976,123 @@
3961
3976
  * Reads the report and draws it.
3962
3977
  * @returns {Promise<void>} - Resolves once drawn.
3963
3978
  */
3979
+ /**
3980
+ * Draws a series as an SVG path, scaled to a shared maximum.
3981
+ *
3982
+ * Inline rather than through a charting library. The console is one
3983
+ * self-contained file served by the node itself, and pulling in a
3984
+ * dependency to draw two lines would be a larger decision than the
3985
+ * feature -- it would have to be vendored, kept current, and would arrive
3986
+ * on every page load for a panel most visits never open.
3987
+ *
3988
+ * @param {object[]} points - The series.
3989
+ * @param {string} key - Which field to plot.
3990
+ * @param {number} peak - Value at the top of the chart, shared by both.
3991
+ * @param {number} width - Viewbox width.
3992
+ * @param {number} height - Viewbox height.
3993
+ * @returns {string} - A path's `d` attribute.
3994
+ */
3995
+ function trafficPath(points, key, peak, width, height) {
3996
+ if (points.length === 0) return '';
3997
+ const step = points.length > 1 ? width / (points.length - 1) : width;
3998
+ return points
3999
+ .map((point, index) => {
4000
+ const x = (index * step).toFixed(1);
4001
+ // Guarded against a peak of zero: an idle week is a flat line along
4002
+ // the bottom, not a division by zero that renders nothing at all.
4003
+ const y = (height - (peak ? (point[key] / peak) * height : 0)).toFixed(1);
4004
+ return `${index === 0 ? 'M' : 'L'}${x} ${y}`;
4005
+ })
4006
+ .join(' ');
4007
+ }
4008
+
4009
+ /**
4010
+ * The whole-node bandwidth chart, and what each archive contributed.
4011
+ * @param {object} report - From /api/traffic.
4012
+ * @returns {string} - Markup.
4013
+ */
4014
+ function renderSwarmTraffic(report) {
4015
+ const points = report.points ?? [];
4016
+ if (points.length === 0) {
4017
+ return `<div class="panel"><div class="sub">
4018
+ Nothing recorded yet. Samples are taken every
4019
+ ${report.sampleSeconds}s, so a chart appears once a couple have
4020
+ been.</div></div>`;
4021
+ }
4022
+
4023
+ // One scale for both lines. Plotting each against its own maximum
4024
+ // would draw a node uploading 2 KB/s and downloading 200 MB/s as two
4025
+ // similar-looking lines, which is the opposite of what a chart is for.
4026
+ const peak = Math.max(
4027
+ 1,
4028
+ ...points.map((point) => Math.max(point.down, point.up)),
4029
+ );
4030
+ const W = 1000;
4031
+ const H = 160;
4032
+ const down = trafficPath(points, 'down', peak, W, H);
4033
+ const up = trafficPath(points, 'up', peak, W, H);
4034
+ const span = (report.to - report.from) / 3600;
4035
+
4036
+ const rows = (report.totals ?? [])
4037
+ .map((row) => {
4038
+ const held = archives.find((a) => a.infoHash === row.infoHash);
4039
+ const name = held?.name ?? `${row.infoHash.slice(0, 12)}…`;
4040
+ return `<tr>
4041
+ <td><b>${escapeHtml(name)}</b>
4042
+ <div class="sub">${escapeHtml(row.infoHash.slice(0, 12))}…</div></td>
4043
+ <td>${bytes(row.down)}</td>
4044
+ <td>${bytes(row.up)}</td>
4045
+ </tr>`;
4046
+ })
4047
+ .join('');
4048
+
4049
+ return `<div class="panel">
4050
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
4051
+ style="width:100%;height:160px;display:block"
4052
+ role="img" aria-label="upload and download over the last ${span.toFixed(0)} hours">
4053
+ <path d="${down}" fill="none" stroke="var(--accent)" stroke-width="2"
4054
+ vector-effect="non-scaling-stroke" />
4055
+ <path d="${up}" fill="none" stroke="var(--ok)" stroke-width="2"
4056
+ vector-effect="non-scaling-stroke" />
4057
+ </svg>
4058
+ <div class="sub" style="display:flex;gap:1rem;flex-wrap:wrap">
4059
+ <span><b style="color:var(--accent)">—</b> down</span>
4060
+ <span><b style="color:var(--ok)">—</b> up</span>
4061
+ <span>peak ${bytes(peak)}/s</span>
4062
+ <span>${points.length} points, ${report.seconds}s each</span>
4063
+ </div>
4064
+ </div>
4065
+ ${
4066
+ rows
4067
+ ? `<div class="panel"><table class="rows">
4068
+ <thead><tr><th>Archive</th><th>Downloaded</th><th>Uploaded</th></tr></thead>
4069
+ <tbody>${rows}</tbody></table>
4070
+ <div class="sub">Bytes are the sampled speed held across each
4071
+ interval, so they follow the chart above rather than the
4072
+ engine's own counters.</div></div>`
4073
+ : ''
4074
+ }`;
4075
+ }
4076
+
4077
+ /** Loads and draws the swarm bandwidth panel. @returns {Promise<void>} */
4078
+ async function loadSwarmTraffic() {
4079
+ const host = $('swarm-traffic');
4080
+ const hours = Number($('traffic-window').value) || 24;
4081
+ try {
4082
+ const report = await api(`/api/traffic?hours=${hours}`);
4083
+ $('traffic-window-note').textContent =
4084
+ `sampled every ${report.sampleSeconds}s, kept for ${report.keepHours}h`;
4085
+ host.innerHTML = renderSwarmTraffic(report);
4086
+ } catch (error) {
4087
+ if (error.message?.includes('disabled')) {
4088
+ host.innerHTML =
4089
+ '<div class="panel"><div class="sub">Swarm statistics are turned off on this node — set <code>traffic</code> in the configuration to enable them.</div></div>';
4090
+ return;
4091
+ }
4092
+ host.innerHTML = `<div class="panel"><div class="sub">Could not load: ${escapeHtml(error.message)}</div></div>`;
4093
+ }
4094
+ }
4095
+
3964
4096
  async function loadTraffic() {
3965
4097
  const body = $('traffic-body');
3966
4098
  let report;
@@ -4058,14 +4190,21 @@
4058
4190
  clearInterval(trafficTimer);
4059
4191
  if (!$('traffic-live').checked) return;
4060
4192
  trafficTimer = setInterval(() => {
4061
- if (!$('view-traffic').hidden) loadTraffic().catch(() => {});
4193
+ if (!$('view-traffic').hidden) {
4194
+ loadTraffic().catch(() => {});
4195
+ loadSwarmTraffic();
4196
+ }
4062
4197
  }, 5000);
4063
4198
  };
4064
4199
 
4065
- $('traffic-refresh').onclick = () => loadTraffic().catch((e) => toast(e.message));
4200
+ $('traffic-refresh').onclick = () => {
4201
+ loadTraffic().catch((e) => toast(e.message));
4202
+ loadSwarmTraffic();
4203
+ };
4066
4204
  $('traffic-live').onchange = trafficPoll;
4067
4205
  trafficPoll();
4068
4206
 
4207
+ $('traffic-window').onchange = () => loadSwarmTraffic();
4069
4208
  $('traffic-reset').onclick = async () => {
4070
4209
  // Deliberately separate from reading: a page polling the endpoint must
4071
4210
  // never be able to erase the history it is drawing.
@@ -482,6 +482,23 @@
482
482
  largest: (a, b) => (b.size ?? 0) - (a.size ?? 0),
483
483
  };
484
484
 
485
+ // The same three orderings against a category, which keeps its facts in a
486
+ // different shape: its own name, and the build it currently points at.
487
+ //
488
+ // Sorting only the archives was the same thing as not sorting at all for
489
+ // most visitors. Categories are the list worth reading -- they follow the
490
+ // newest build, which is what a style should hold -- so they are rendered
491
+ // first and are what somebody watches while changing the control. A node
492
+ // carrying twenty of them and a handful of archives looked completely
493
+ // unresponsive to it.
494
+ const categorySorters = {
495
+ name: (a, b) =>
496
+ String(a.category ?? '').localeCompare(String(b.category ?? '')),
497
+ newest: (a, b) =>
498
+ new Date(b.newest?.createdAt ?? 0) - new Date(a.newest?.createdAt ?? 0),
499
+ largest: (a, b) => (b.newest?.size ?? 0) - (a.newest?.size ?? 0),
500
+ };
501
+
485
502
  // Re-renders both lists against the current filter and sort.
486
503
  const apply = () => {
487
504
  const needle = (document.getElementById('filter').value ?? '')
@@ -499,12 +516,14 @@
499
516
  )
500
517
  .sort(sorters[order] ?? sorters.name);
501
518
 
502
- const categories = loadedCategories.filter(
503
- (entry) =>
504
- needle === '' ||
505
- matches(entry.category, needle) ||
506
- matches(entry.newest?.name, needle),
507
- );
519
+ const categories = loadedCategories
520
+ .filter(
521
+ (entry) =>
522
+ needle === '' ||
523
+ matches(entry.category, needle) ||
524
+ matches(entry.newest?.name, needle),
525
+ )
526
+ .sort(categorySorters[order] ?? categorySorters.name);
508
527
 
509
528
  renderCategories(categories);
510
529
  render(archives);