pmtiles-swarm 0.26.1 → 0.28.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,78 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.28.0
11
+ ### ✨ Features and improvements
12
+ - **The archive list can be searched and sorted, and says when each archive was added.** `Added` is
13
+ a column now — `createdAt` has always been on every entry and returned by `/api/catalog`, it was
14
+ simply never shown — with a date for anything older than today and a time for today, since the
15
+ question a list answers is which of these is recent rather than exactly when each arrived.
16
+
17
+ Beside it, the same filter and sort the public page has: by name, infohash or category, ordered
18
+ by newest added, oldest added, name or size — and by download speed, upload speed or share
19
+ ratio, which are read off the live status the poll refreshes, so rows reorder themselves under
20
+ those every few seconds. That is what a torrent client does and what choosing "download speed"
21
+ asks for, and also why none of them is the default. Newest added is, because a list read
22
+ straight after adding something should have that thing at the top.
23
+
24
+ Both happen inside the render rather than where the data arrives. The list refreshes every three
25
+ seconds; filtering at the fetch would either clear what had been typed on each poll or refetch
26
+ the whole catalog on every keystroke. And the count beside the box says `4 of 37` while a filter
27
+ is narrowing things, because an empty table and a table filtered down to nothing look identical.
28
+
29
+ ### 🐞 Bug fixes
30
+
31
+ ## 0.27.0
32
+ ### ✨ Features and improvements
33
+ - **Bandwidth history per archive, kept across restarts.** The tile side of this question already
34
+
35
+ The Traffic tab draws it: one chart for the node with a window of an hour, a day or a week, and
36
+ a table of what each archive moved over that window. Drawn as inline SVG rather than through a
37
+ charting library — the console is a single self-contained file the node serves itself, and a
38
+ dependency for two lines would have to be vendored, kept current, and shipped on every page load
39
+ for a panel most visits never open. Both lines share one scale, because drawing each against its
40
+ own maximum renders a node uploading 2 KB/s and downloading 200 MB/s as two similar lines, which
41
+ is the opposite of what a chart is for.
42
+ had an answer; the swarm side had none. An archive could seed steadily for a day and leave no
43
+ trace but a speed in the console that is gone the moment you look away, which makes "what is
44
+ using the bandwidth" and "is this archive earning its disk" unanswerable.
45
+
46
+ Upload and download speed are now sampled per archive on a timer and kept in `stats.db`, beside
47
+ the catalog in `dataDir` — not beside the config, which is the operator's: hand-edited, diffed,
48
+ copied between nodes, and the thing you reach for when a node will not start. A database that
49
+ grows on its own does not belong there. `node:sqlite` is built in and already used for MBTiles,
50
+ so this costs no dependency, and it is imported lazily for the same reason `mbtiles.js` does it:
51
+ the first require prints an experimental warning nobody with this switched off should have to
52
+ explain.
53
+
54
+ Persisted rather than held in memory, unlike `tileStats`, because it answers a question about
55
+ the past — restarting to pick up a new version would erase exactly the week somebody wanted to
56
+ look at. Application logs stay in the journal; this is only for numbers that have to survive a
57
+ restart.
58
+
59
+ Two settings, because they are two questions: `traffic.sampleSeconds` is how finely it looks and
60
+ `traffic.keepHours` how far back it remembers, defaulting to every 15 seconds for a week. Read
61
+ back through `GET /api/traffic`, averaged into buckets so a week of samples is a graph rather
62
+ than forty thousand points, with `totals` ranking archives by bytes moved. `traffic: false`
63
+ turns the whole thing off, and a database that cannot be opened is reported rather than fatal —
64
+ a node that cannot record what it moved should still move it.
65
+
66
+ ### 🐞 Bug fixes
67
+
68
+ ## 0.26.2
69
+ ### 🐞 Bug fixes
70
+ - **The public page's sort left the categories alone.** `apply()` sorted the archives and handed
71
+ the categories straight to the renderer, which does not sort either — so on a node carrying
72
+ twenty categories and a few archives the control looked completely dead. Categories are rendered
73
+ first and are the list worth reading, since they follow the newest build, so sorting only the
74
+ other list amounted to not sorting at all for most visitors.
75
+
76
+ All three orderings now apply to both, against the fields a category actually keeps: its own
77
+ name, and the date and size of the build it points at. The existing test could not have caught
78
+ this — it checked that every option in the select had a comparator behind it, which was true, and
79
+ said nothing about whether the categories were ever handed to one. The comparators are now lifted
80
+ out of the page and called directly.
81
+
10
82
  ## 0.26.1
11
83
  ### 🐞 Bug fixes
12
84
  - **A download that finished was fetched all over again.** Resuming looked only at the
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.1",
3
+ "version": "0.28.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
  },
@@ -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
+ }
@@ -324,12 +324,36 @@
324
324
  <button type="button">Feed</button>
325
325
  </a>
326
326
  </div>
327
+ <div class="bar">
328
+ <input
329
+ id="archive-filter"
330
+ type="search"
331
+ placeholder="Filter by name, infohash or category"
332
+ autocomplete="off"
333
+ spellcheck="false"
334
+ style="flex:1;min-width:12rem"
335
+ />
336
+ <label>
337
+ Sort
338
+ <select id="archive-sort">
339
+ <option value="added">newest added</option>
340
+ <option value="oldest">oldest added</option>
341
+ <option value="name">name</option>
342
+ <option value="largest">largest</option>
343
+ <option value="down">download speed</option>
344
+ <option value="up">upload speed</option>
345
+ <option value="ratio">share ratio</option>
346
+ </select>
347
+ </label>
348
+ <span class="sub" id="archive-count"></span>
349
+ </div>
327
350
 
328
351
  <table>
329
352
  <thead>
330
353
  <tr>
331
354
  <th>Archive</th>
332
355
  <th>Size</th>
356
+ <th>Added</th>
333
357
  <th>Mode</th>
334
358
  <th>Origin</th>
335
359
  <th>Progress</th>
@@ -375,6 +399,18 @@
375
399
  </label>
376
400
  <button id="traffic-reset" style="margin-left:auto">Reset counters</button>
377
401
  </div>
402
+ <div class="bar">
403
+ <label>
404
+ Swarm bandwidth
405
+ <select id="traffic-window">
406
+ <option value="1">last hour</option>
407
+ <option value="24" selected>last 24 hours</option>
408
+ <option value="168">last week</option>
409
+ </select>
410
+ </label>
411
+ <span class="sub" id="traffic-window-note"></span>
412
+ </div>
413
+ <div id="swarm-traffic"><div class="sub">loading…</div></div>
378
414
  <div id="traffic-body"><div class="sub">loading…</div></div>
379
415
  </section>
380
416
 
@@ -698,6 +734,56 @@
698
734
  const rate = (n) => (n > 0 ? `${bytes(n)}/s` : '—');
699
735
  const pct = (n) => `${Math.round((n ?? 0) * 100)}%`;
700
736
 
737
+ /**
738
+ * When an archive was added, as something short enough for a column.
739
+ *
740
+ * A date on its own for anything older than today, and a time for
741
+ * today: the question a list answers is "which of these is recent", and
742
+ * a full timestamp on every row is harder to scan than either.
743
+ * @param {string} value - ISO timestamp.
744
+ * @returns {string} - Markup for the cell.
745
+ */
746
+ const added = (value) => {
747
+ if (!value) return '<span class="sub">—</span>';
748
+ const when = new Date(value);
749
+ if (Number.isNaN(when.getTime())) return '<span class="sub">—</span>';
750
+ const today = new Date().toDateString() === when.toDateString();
751
+ return `<span title="${escapeHtml(when.toLocaleString())}">${
752
+ today
753
+ ? when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
754
+ : when.toLocaleDateString()
755
+ }</span>`;
756
+ };
757
+
758
+ // The same three questions the public page asks, against an admin row.
759
+ // Newest first by default: a list read after adding something is a list
760
+ // where the thing just added should be at the top.
761
+ /**
762
+ * A live figure off an archive's status, or zero.
763
+ *
764
+ * An archive the engine has not reported on yet has no status at all,
765
+ * and comparing undefined gives NaN -- which makes a sort silently do
766
+ * nothing rather than fail, so it is worth the guard.
767
+ * @param {object} entry - Catalog entry with its status attached.
768
+ * @param {string} field - Which figure.
769
+ * @returns {number} - The value, or zero.
770
+ */
771
+ const speedOf = (entry, field) => Number(entry?.status?.[field] ?? 0) || 0;
772
+
773
+ const archiveSorters = {
774
+ added: (a, b) => new Date(b.createdAt ?? 0) - new Date(a.createdAt ?? 0),
775
+ oldest: (a, b) => new Date(a.createdAt ?? 0) - new Date(b.createdAt ?? 0),
776
+ name: (a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')),
777
+ largest: (a, b) => (b.size ?? 0) - (a.size ?? 0),
778
+ // Live values, read off the status the poll refreshes. Rows reorder
779
+ // themselves every few seconds under these, which is what a torrent
780
+ // client does and what somebody choosing "download speed" is asking
781
+ // for -- but it is also why they are not the default.
782
+ down: (a, b) => speedOf(b, 'downloadSpeed') - speedOf(a, 'downloadSpeed'),
783
+ up: (a, b) => speedOf(b, 'uploadSpeed') - speedOf(a, 'uploadSpeed'),
784
+ ratio: (a, b) => speedOf(b, 'ratio') - speedOf(a, 'ratio'),
785
+ };
786
+
701
787
  let toastTimer;
702
788
  const toast = (message) => {
703
789
  $('toast').textContent = message;
@@ -992,9 +1078,34 @@
992
1078
  function renderRows() {
993
1079
  const rows = $('rows');
994
1080
  rows.innerHTML = '';
1081
+
1082
+ // Filtered and sorted here rather than where the data arrives, so the
1083
+ // three-second poll keeps refreshing what is on screen without
1084
+ // resetting what was typed. `archives` stays the node's answer; this
1085
+ // is only the view of it.
1086
+ const needle = ($('archive-filter')?.value ?? '').trim().toLowerCase();
1087
+ const order = $('archive-sort')?.value ?? 'added';
1088
+ const shown = archives
1089
+ .filter(
1090
+ (entry) =>
1091
+ needle === '' ||
1092
+ String(entry.name ?? '').toLowerCase().includes(needle) ||
1093
+ String(entry.infoHash ?? '').toLowerCase().includes(needle) ||
1094
+ (entry.categories ?? []).some((tag) =>
1095
+ String(tag).toLowerCase().includes(needle),
1096
+ ),
1097
+ )
1098
+ .sort(archiveSorters[order] ?? archiveSorters.added);
1099
+
995
1100
  $('empty').hidden = archives.length > 0;
1101
+ // Says when a filter is hiding things, because an empty table and a
1102
+ // table filtered down to nothing look identical otherwise.
1103
+ $('archive-count').textContent =
1104
+ needle === ''
1105
+ ? ''
1106
+ : `${shown.length} of ${archives.length}`;
996
1107
 
997
- for (const entry of archives) {
1108
+ for (const entry of shown) {
998
1109
  const s = entry.status ?? {};
999
1110
  const progress = s.progress ?? 0;
1000
1111
  const mode = entry.mode ?? 'mirror';
@@ -1012,6 +1123,7 @@
1012
1123
  ? `<div class="sub">${escapeHtml(entry.kind)} · not servable</div>`
1013
1124
  : ''
1014
1125
  }</td>
1126
+ <td>${added(entry.createdAt)}</td>
1015
1127
  <td><span class="pill ${mode}">${mode}</span></td>
1016
1128
  <td>${originCell(entry)}</td>
1017
1129
  <td>
@@ -3919,7 +4031,10 @@
3919
4031
  }
3920
4032
  if (name === 'settings') loadSettings().catch((e) => toast(e.message));
3921
4033
  if (name === 'categories') loadCategories().catch((e) => toast(e.message));
3922
- if (name === 'traffic') loadTraffic().catch((e) => toast(e.message));
4034
+ if (name === 'traffic') {
4035
+ loadTraffic().catch((e) => toast(e.message));
4036
+ loadSwarmTraffic();
4037
+ }
3923
4038
  };
3924
4039
  // The footer's year, set from the clock rather than typed into a file
3925
4040
  // nobody will remember to edit. The version beside it arrives with the
@@ -3961,6 +4076,123 @@
3961
4076
  * Reads the report and draws it.
3962
4077
  * @returns {Promise<void>} - Resolves once drawn.
3963
4078
  */
4079
+ /**
4080
+ * Draws a series as an SVG path, scaled to a shared maximum.
4081
+ *
4082
+ * Inline rather than through a charting library. The console is one
4083
+ * self-contained file served by the node itself, and pulling in a
4084
+ * dependency to draw two lines would be a larger decision than the
4085
+ * feature -- it would have to be vendored, kept current, and would arrive
4086
+ * on every page load for a panel most visits never open.
4087
+ *
4088
+ * @param {object[]} points - The series.
4089
+ * @param {string} key - Which field to plot.
4090
+ * @param {number} peak - Value at the top of the chart, shared by both.
4091
+ * @param {number} width - Viewbox width.
4092
+ * @param {number} height - Viewbox height.
4093
+ * @returns {string} - A path's `d` attribute.
4094
+ */
4095
+ function trafficPath(points, key, peak, width, height) {
4096
+ if (points.length === 0) return '';
4097
+ const step = points.length > 1 ? width / (points.length - 1) : width;
4098
+ return points
4099
+ .map((point, index) => {
4100
+ const x = (index * step).toFixed(1);
4101
+ // Guarded against a peak of zero: an idle week is a flat line along
4102
+ // the bottom, not a division by zero that renders nothing at all.
4103
+ const y = (height - (peak ? (point[key] / peak) * height : 0)).toFixed(1);
4104
+ return `${index === 0 ? 'M' : 'L'}${x} ${y}`;
4105
+ })
4106
+ .join(' ');
4107
+ }
4108
+
4109
+ /**
4110
+ * The whole-node bandwidth chart, and what each archive contributed.
4111
+ * @param {object} report - From /api/traffic.
4112
+ * @returns {string} - Markup.
4113
+ */
4114
+ function renderSwarmTraffic(report) {
4115
+ const points = report.points ?? [];
4116
+ if (points.length === 0) {
4117
+ return `<div class="panel"><div class="sub">
4118
+ Nothing recorded yet. Samples are taken every
4119
+ ${report.sampleSeconds}s, so a chart appears once a couple have
4120
+ been.</div></div>`;
4121
+ }
4122
+
4123
+ // One scale for both lines. Plotting each against its own maximum
4124
+ // would draw a node uploading 2 KB/s and downloading 200 MB/s as two
4125
+ // similar-looking lines, which is the opposite of what a chart is for.
4126
+ const peak = Math.max(
4127
+ 1,
4128
+ ...points.map((point) => Math.max(point.down, point.up)),
4129
+ );
4130
+ const W = 1000;
4131
+ const H = 160;
4132
+ const down = trafficPath(points, 'down', peak, W, H);
4133
+ const up = trafficPath(points, 'up', peak, W, H);
4134
+ const span = (report.to - report.from) / 3600;
4135
+
4136
+ const rows = (report.totals ?? [])
4137
+ .map((row) => {
4138
+ const held = archives.find((a) => a.infoHash === row.infoHash);
4139
+ const name = held?.name ?? `${row.infoHash.slice(0, 12)}…`;
4140
+ return `<tr>
4141
+ <td><b>${escapeHtml(name)}</b>
4142
+ <div class="sub">${escapeHtml(row.infoHash.slice(0, 12))}…</div></td>
4143
+ <td>${bytes(row.down)}</td>
4144
+ <td>${bytes(row.up)}</td>
4145
+ </tr>`;
4146
+ })
4147
+ .join('');
4148
+
4149
+ return `<div class="panel">
4150
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"
4151
+ style="width:100%;height:160px;display:block"
4152
+ role="img" aria-label="upload and download over the last ${span.toFixed(0)} hours">
4153
+ <path d="${down}" fill="none" stroke="var(--accent)" stroke-width="2"
4154
+ vector-effect="non-scaling-stroke" />
4155
+ <path d="${up}" fill="none" stroke="var(--ok)" stroke-width="2"
4156
+ vector-effect="non-scaling-stroke" />
4157
+ </svg>
4158
+ <div class="sub" style="display:flex;gap:1rem;flex-wrap:wrap">
4159
+ <span><b style="color:var(--accent)">—</b> down</span>
4160
+ <span><b style="color:var(--ok)">—</b> up</span>
4161
+ <span>peak ${bytes(peak)}/s</span>
4162
+ <span>${points.length} points, ${report.seconds}s each</span>
4163
+ </div>
4164
+ </div>
4165
+ ${
4166
+ rows
4167
+ ? `<div class="panel"><table class="rows">
4168
+ <thead><tr><th>Archive</th><th>Downloaded</th><th>Uploaded</th></tr></thead>
4169
+ <tbody>${rows}</tbody></table>
4170
+ <div class="sub">Bytes are the sampled speed held across each
4171
+ interval, so they follow the chart above rather than the
4172
+ engine's own counters.</div></div>`
4173
+ : ''
4174
+ }`;
4175
+ }
4176
+
4177
+ /** Loads and draws the swarm bandwidth panel. @returns {Promise<void>} */
4178
+ async function loadSwarmTraffic() {
4179
+ const host = $('swarm-traffic');
4180
+ const hours = Number($('traffic-window').value) || 24;
4181
+ try {
4182
+ const report = await api(`/api/traffic?hours=${hours}`);
4183
+ $('traffic-window-note').textContent =
4184
+ `sampled every ${report.sampleSeconds}s, kept for ${report.keepHours}h`;
4185
+ host.innerHTML = renderSwarmTraffic(report);
4186
+ } catch (error) {
4187
+ if (error.message?.includes('disabled')) {
4188
+ host.innerHTML =
4189
+ '<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>';
4190
+ return;
4191
+ }
4192
+ host.innerHTML = `<div class="panel"><div class="sub">Could not load: ${escapeHtml(error.message)}</div></div>`;
4193
+ }
4194
+ }
4195
+
3964
4196
  async function loadTraffic() {
3965
4197
  const body = $('traffic-body');
3966
4198
  let report;
@@ -4058,14 +4290,23 @@
4058
4290
  clearInterval(trafficTimer);
4059
4291
  if (!$('traffic-live').checked) return;
4060
4292
  trafficTimer = setInterval(() => {
4061
- if (!$('view-traffic').hidden) loadTraffic().catch(() => {});
4293
+ if (!$('view-traffic').hidden) {
4294
+ loadTraffic().catch(() => {});
4295
+ loadSwarmTraffic();
4296
+ }
4062
4297
  }, 5000);
4063
4298
  };
4064
4299
 
4065
- $('traffic-refresh').onclick = () => loadTraffic().catch((e) => toast(e.message));
4300
+ $('traffic-refresh').onclick = () => {
4301
+ loadTraffic().catch((e) => toast(e.message));
4302
+ loadSwarmTraffic();
4303
+ };
4066
4304
  $('traffic-live').onchange = trafficPoll;
4067
4305
  trafficPoll();
4068
4306
 
4307
+ $('archive-filter').addEventListener('input', renderRows);
4308
+ $('archive-sort').addEventListener('change', renderRows);
4309
+ $('traffic-window').onchange = () => loadSwarmTraffic();
4069
4310
  $('traffic-reset').onclick = async () => {
4070
4311
  // Deliberately separate from reading: a page polling the endpoint must
4071
4312
  // 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);