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 +70 -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/torrent-create.js +106 -15
- package/src/traffic-stats.js +306 -0
- package/src/web/index.html +142 -3
- package/src/web/public.html +25 -6
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.
|
|
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
|
},
|
package/src/torrent-create.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/web/index.html
CHANGED
|
@@ -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')
|
|
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)
|
|
4193
|
+
if (!$('view-traffic').hidden) {
|
|
4194
|
+
loadTraffic().catch(() => {});
|
|
4195
|
+
loadSwarmTraffic();
|
|
4196
|
+
}
|
|
4062
4197
|
}, 5000);
|
|
4063
4198
|
};
|
|
4064
4199
|
|
|
4065
|
-
$('traffic-refresh').onclick = () =>
|
|
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.
|
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);
|