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 +72 -0
- package/README.md +1 -0
- package/docs/running-as-a-service.md +56 -0
- package/package.json +1 -1
- package/src/api.js +28 -0
- package/src/config.js +20 -0
- package/src/index.js +33 -0
- package/src/traffic-stats.js +306 -0
- package/src/web/index.html +245 -4
- package/src/web/public.html +25 -6
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.
|
|
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
|
+
}
|
package/src/web/index.html
CHANGED
|
@@ -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
|
|
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')
|
|
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)
|
|
4293
|
+
if (!$('view-traffic').hidden) {
|
|
4294
|
+
loadTraffic().catch(() => {});
|
|
4295
|
+
loadSwarmTraffic();
|
|
4296
|
+
}
|
|
4062
4297
|
}, 5000);
|
|
4063
4298
|
};
|
|
4064
4299
|
|
|
4065
|
-
$('traffic-refresh').onclick = () =>
|
|
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.
|
package/src/web/public.html
CHANGED
|
@@ -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
|
|
503
|
-
(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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);
|