pmtiles-swarm 0.5.5 β 0.6.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 +47 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/api.js +51 -0
- package/src/engines/libtorrent.js +34 -1
- package/src/hooks.js +42 -0
- package/src/index.js +2 -0
- package/src/web/index.html +124 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@
|
|
|
7
7
|
### π Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.6.0
|
|
11
|
+
### β¨ Features and improvements
|
|
12
|
+
- **Check now, on scheduled sources and on feeds.** A schedule describes ordinary operation, and
|
|
13
|
+
setting one up is not ordinary operation β waiting six hours to find out whether a URL template
|
|
14
|
+
is right is how a typo survives a working day. `POST /api/sources/check` is new; the feed
|
|
15
|
+
equivalent existed and had no button. Both check what is *saved* rather than what is on screen,
|
|
16
|
+
since an unsaved row is not a source this node knows about.
|
|
17
|
+
- **Run the completion hook again for one archive**, from its General tab or
|
|
18
|
+
`POST /api/torrents/<infohash>/hooks/complete`. Completion is recorded before the command runs,
|
|
19
|
+
so a build taking six hours is not started six times over β but a hook that failed for a reason
|
|
20
|
+
since fixed keeps that record too, and the only way to run it again was to stop the node and
|
|
21
|
+
edit the catalog by hand. Started rather than awaited, since the command may be a planet build;
|
|
22
|
+
refused with 409 if it is already running for that archive, because two builds writing the same
|
|
23
|
+
output is worse than waiting. It does not choose *which* command β that is still the config
|
|
24
|
+
file's business, and `allowHooksFromApi` still guards choosing it.
|
|
25
|
+
|
|
26
|
+
### π Bug fixes
|
|
27
|
+
- **The node no longer crashes while shutting down.** `child.stdin` had no `error` listener, and
|
|
28
|
+
an unhandled `'error'` event is not a rejected promise β it is a throw that takes the process
|
|
29
|
+
with it. systemd's default `KillMode` signals every process in a service's cgroup, so the Python
|
|
30
|
+
sidecar exited first and the shutdown request was written into a dead pipe:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
[shutdown] SIGTERM
|
|
34
|
+
Error: write EPIPE ... at #call (src/engines/libtorrent.js)
|
|
35
|
+
Main process exited, code=exited, status=1/FAILURE
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every stop ended that way, and because the crash happened *inside* `destroy()`, the shutdown
|
|
39
|
+
that saves resume data never ran β so this was also a third, independent reason resume data
|
|
40
|
+
went missing. `Restart=always` brought the node back five seconds later looking healthy, which
|
|
41
|
+
is why it went unnoticed. A failed write now fails the call that made it; the pipe is checked
|
|
42
|
+
before writing; and there is a test that spawns a sidecar which reports ready and then exits.
|
|
43
|
+
|
|
44
|
+
## 0.5.6
|
|
45
|
+
### π Bug fixes
|
|
46
|
+
- **The Pieces and Peers tabs redraw themselves.** A detail pane is filled once, the first time
|
|
47
|
+
it is opened β right for the panes describing an archive, whose name and source and trackers
|
|
48
|
+
do not change, and wrong for the two describing what is happening *now*. Pieces arrived and
|
|
49
|
+
peers came and went behind a picture taken when the tab was opened, and the only way to see
|
|
50
|
+
the current one was to close the panel and open it again, or reload the page. Those two now
|
|
51
|
+
follow the same three-second tick as the archive table. The rest stay lazy, since redrawing a
|
|
52
|
+
pane nobody is watching is requests every tick for nothing β which is why they load on demand
|
|
53
|
+
in the first place. Only while visible, and never queued behind a redraw still in flight: a
|
|
54
|
+
piece map of a large archive is a real request, and three seconds is not long enough to assume
|
|
55
|
+
the last one finished.
|
|
56
|
+
|
|
10
57
|
## 0.5.5
|
|
11
58
|
### π Bug fixes
|
|
12
59
|
- **An archive could retire itself from head-warming and never say so.** The test for "already
|
package/README.md
CHANGED
|
@@ -604,6 +604,8 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
604
604
|
| `POST` | `/api/sources/preview` | What a watched web location would take, without taking it |
|
|
605
605
|
| `POST` | `/api/subscriptions/preview` | Whether a peer is reachable and what it offers |
|
|
606
606
|
| `POST` | `/api/subscriptions/refresh` | Poll subscribed feeds now |
|
|
607
|
+
| `POST` | `/api/sources/check` | Check scheduled sources now, rather than at the next due time |
|
|
608
|
+
| `POST` | `/api/torrents/:infoHash/hooks/complete` | Run the completion hook again for one archive |
|
|
607
609
|
| `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
|
|
608
610
|
| `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
|
|
609
611
|
| `GET` `PATCH` | `/api/config` | Read and change settings |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -63,6 +63,8 @@ export function createApp({
|
|
|
63
63
|
catalog,
|
|
64
64
|
engine,
|
|
65
65
|
subscriptions,
|
|
66
|
+
sources,
|
|
67
|
+
hooks,
|
|
66
68
|
tiles,
|
|
67
69
|
warm,
|
|
68
70
|
config,
|
|
@@ -1386,6 +1388,55 @@ export function createApp({
|
|
|
1386
1388
|
}),
|
|
1387
1389
|
);
|
|
1388
1390
|
|
|
1391
|
+
// The same for scheduled sources: ask now rather than at the next due time.
|
|
1392
|
+
// Worth having for the same reason a feed refresh is β a schedule is a
|
|
1393
|
+
// statement about ordinary operation, and setting one up is not ordinary
|
|
1394
|
+
// operation. Waiting six hours to find out whether a URL template is right
|
|
1395
|
+
// is how a typo survives a working day.
|
|
1396
|
+
app.post(
|
|
1397
|
+
'/api/sources/check',
|
|
1398
|
+
route(async (_req, res) => {
|
|
1399
|
+
if (!sources?.sweep) {
|
|
1400
|
+
return res.status(501).json({ error: 'no scheduled sources here' });
|
|
1401
|
+
}
|
|
1402
|
+
const taken = await sources.sweep(new Date());
|
|
1403
|
+
res.json({ taken: taken.length, entries: taken });
|
|
1404
|
+
}),
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
// Run the completion hook again for one archive.
|
|
1408
|
+
//
|
|
1409
|
+
// The sweep runs it once and records that it has, so a six-hour build is not
|
|
1410
|
+
// started six times over β but a hook that failed for a reason since fixed
|
|
1411
|
+
// keeps that record too, and the only way to run it again was to stop the
|
|
1412
|
+
// node and edit the catalog by hand.
|
|
1413
|
+
//
|
|
1414
|
+
// A POST, and therefore refused to a read-only token: this runs a command as
|
|
1415
|
+
// the service user. It does not choose *which* command β that is still the
|
|
1416
|
+
// config file's business, and `allowHooksFromApi` still guards it.
|
|
1417
|
+
app.post(
|
|
1418
|
+
'/api/torrents/:infoHash/hooks/complete',
|
|
1419
|
+
route(async (req, res) => {
|
|
1420
|
+
if (!hooks?.runComplete) {
|
|
1421
|
+
return res.status(501).json({ error: 'hooks are not available here' });
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const started = await hooks.runComplete(req.params.infoHash);
|
|
1425
|
+
res.json({ started: true, ...started });
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
// Told apart because they mean different things to whoever pressed
|
|
1428
|
+
// the button: nothing configured, nothing to run it for, or already
|
|
1429
|
+
// going.
|
|
1430
|
+
const status = /unknown archive/.test(error.message)
|
|
1431
|
+
? 404
|
|
1432
|
+
: /already running/.test(error.message)
|
|
1433
|
+
? 409
|
|
1434
|
+
: 400;
|
|
1435
|
+
res.status(status).json({ error: error.message });
|
|
1436
|
+
}
|
|
1437
|
+
}),
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1389
1440
|
/**
|
|
1390
1441
|
* How many items this feed request should return.
|
|
1391
1442
|
*
|
|
@@ -134,6 +134,24 @@ export class LibtorrentEngine {
|
|
|
134
134
|
});
|
|
135
135
|
this.#child = child;
|
|
136
136
|
|
|
137
|
+
// A pipe to a process that has gone raises 'error' on the stream, and an
|
|
138
|
+
// unhandled 'error' event is not a rejected promise β it is a throw that
|
|
139
|
+
// takes the whole node down with it. That is what happened on every
|
|
140
|
+
// stop: systemd's default KillMode signals every process in the cgroup,
|
|
141
|
+
// so the sidecar exited first and the shutdown request was written into
|
|
142
|
+
// a dead pipe. The service died with EPIPE and status=1/FAILURE, having
|
|
143
|
+
// never saved its resume data, and no `.catch()` on the call could have
|
|
144
|
+
// caught it.
|
|
145
|
+
child.stdin.on('error', (error) => {
|
|
146
|
+
if (!this.#stopping) {
|
|
147
|
+
console.warn(`[libtorrent] sidecar input failed: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
for (const [id, waiter] of this.#pending) {
|
|
150
|
+
this.#pending.delete(id);
|
|
151
|
+
waiter.reject(error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
137
155
|
const timer = setTimeout(() => {
|
|
138
156
|
reject(
|
|
139
157
|
new Error(
|
|
@@ -481,7 +499,22 @@ export class LibtorrentEngine {
|
|
|
481
499
|
},
|
|
482
500
|
});
|
|
483
501
|
|
|
484
|
-
|
|
502
|
+
// Checked and reported rather than thrown at. Writing to a closed pipe
|
|
503
|
+
// is ordinary during shutdown, and a call that cannot be sent should
|
|
504
|
+
// fail as a call rather than as the process.
|
|
505
|
+
if (!child.stdin.writable || child.stdin.destroyed) {
|
|
506
|
+
this.#pending.delete(id);
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
reject(new Error(`libtorrent ${op}: the sidecar is no longer running`));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
child.stdin.write(`${JSON.stringify({ id, op, params })}\n`, (error) => {
|
|
513
|
+
if (!error) return;
|
|
514
|
+
this.#pending.delete(id);
|
|
515
|
+
clearTimeout(timer);
|
|
516
|
+
reject(new Error(`libtorrent ${op}: ${error.message}`));
|
|
517
|
+
});
|
|
485
518
|
});
|
|
486
519
|
}
|
|
487
520
|
|
package/src/hooks.js
CHANGED
|
@@ -193,6 +193,48 @@ export class ProgramHooks {
|
|
|
193
193
|
return fired;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Runs the completion hook for one archive, now, whatever it did before.
|
|
198
|
+
*
|
|
199
|
+
* The sweep runs a hook once and records that it has: a build taking six
|
|
200
|
+
* hours must not be started six times over. But a hook that failed for a
|
|
201
|
+
* reason since fixed β a path that was wrong, a directory that was not
|
|
202
|
+
* writable β keeps that record too, and until now the only way to run it
|
|
203
|
+
* again was to stop the node and edit the catalog by hand.
|
|
204
|
+
*
|
|
205
|
+
* Started rather than awaited. The command may be a planet build, and a
|
|
206
|
+
* request that waited for it would time out long before it finished.
|
|
207
|
+
* @param {string} infoHash - The archive to run it for.
|
|
208
|
+
* @returns {Promise<object>} - What was started.
|
|
209
|
+
*/
|
|
210
|
+
async runComplete(infoHash) {
|
|
211
|
+
if (!this.#config.onComplete?.command) {
|
|
212
|
+
throw new Error('no onComplete hook is configured');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const live = await this.#library.listWithStatus().catch(() => []);
|
|
216
|
+
const entry = live.find((candidate) => candidate.infoHash === infoHash);
|
|
217
|
+
if (!entry) throw new Error('unknown archive');
|
|
218
|
+
if (this.#running.has(infoHash)) {
|
|
219
|
+
throw new Error('it is already running for this archive');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Recorded before starting, the same as the sweep does and for the same
|
|
223
|
+
// reason: whatever happens next, this is not a fresh completion to be
|
|
224
|
+
// picked up again a minute later.
|
|
225
|
+
await this.#library.catalog.put({
|
|
226
|
+
infoHash,
|
|
227
|
+
completedAt: new Date().toISOString(),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
this.#running.add(infoHash);
|
|
231
|
+
this.#fire('onComplete', entry)
|
|
232
|
+
.catch(() => {})
|
|
233
|
+
.finally(() => this.#running.delete(infoHash));
|
|
234
|
+
|
|
235
|
+
return { name: entry.name, command: this.#config.onComplete.command };
|
|
236
|
+
}
|
|
237
|
+
|
|
196
238
|
/**
|
|
197
239
|
* Runs one of the configured hooks, if it is configured.
|
|
198
240
|
* @param {string} which - 'onAdded' or 'onComplete'.
|
package/src/index.js
CHANGED
package/src/web/index.html
CHANGED
|
@@ -729,6 +729,21 @@
|
|
|
729
729
|
// before the node has said whether its engine applies one.
|
|
730
730
|
let incompleteMarker = null;
|
|
731
731
|
let activeTab = 'general';
|
|
732
|
+
/**
|
|
733
|
+
* Panes worth redrawing while they are open.
|
|
734
|
+
*
|
|
735
|
+
* A detail pane is filled once, the first time it is shown, which is
|
|
736
|
+
* right for the ones describing an archive: its name, its source, its
|
|
737
|
+
* trackers. It is wrong for these two, which describe what is happening
|
|
738
|
+
* *now* β pieces arriving and peers coming and going β and which sat
|
|
739
|
+
* frozen until the reader thought to close the panel and open it again.
|
|
740
|
+
*
|
|
741
|
+
* The rest stay as they are. Refreshing a pane nobody is watching is
|
|
742
|
+
* four requests a tick for nothing, which is why they are loaded lazily
|
|
743
|
+
* in the first place.
|
|
744
|
+
*/
|
|
745
|
+
const LIVE_PANES = new Set(['pieces', 'peers']);
|
|
746
|
+
let refreshingPane = false;
|
|
732
747
|
|
|
733
748
|
// What the header switch shows, so a click knows what it is toggling to.
|
|
734
749
|
let speedState = null;
|
|
@@ -894,6 +909,7 @@
|
|
|
894
909
|
` Β· <b>${status.archives}</b> archives`;
|
|
895
910
|
archives = list;
|
|
896
911
|
renderRows();
|
|
912
|
+
refreshOpenPane();
|
|
897
913
|
} catch (error) {
|
|
898
914
|
$('status').textContent = `disconnected: ${error.message}`;
|
|
899
915
|
}
|
|
@@ -1072,6 +1088,29 @@
|
|
|
1072
1088
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
1073
1089
|
);
|
|
1074
1090
|
|
|
1091
|
+
/**
|
|
1092
|
+
* Redraws the open detail pane, when it is one that changes by itself.
|
|
1093
|
+
*
|
|
1094
|
+
* Guarded against overlapping, because a piece map of a large archive is
|
|
1095
|
+
* a real request and a slow one must not have a queue of its successors
|
|
1096
|
+
* behind it.
|
|
1097
|
+
* @returns {void}
|
|
1098
|
+
*/
|
|
1099
|
+
function refreshOpenPane() {
|
|
1100
|
+
if (refreshingPane || !selected || !LIVE_PANES.has(activeTab)) return;
|
|
1101
|
+
const pane = document.querySelector(`[data-pane="${activeTab}"]`);
|
|
1102
|
+
// Only while it is on screen: a hidden pane is one the reader moved
|
|
1103
|
+
// away from, and the panel may have been replaced since.
|
|
1104
|
+
if (!pane || pane.hidden) return;
|
|
1105
|
+
|
|
1106
|
+
refreshingPane = true;
|
|
1107
|
+
Promise.resolve(fillPane(activeTab, selected))
|
|
1108
|
+
.catch(() => {})
|
|
1109
|
+
.finally(() => {
|
|
1110
|
+
refreshingPane = false;
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1075
1114
|
/**
|
|
1076
1115
|
* Fills one detail tab, the first time it is opened.
|
|
1077
1116
|
* @param {string} name - Which pane.
|
|
@@ -1347,6 +1386,20 @@
|
|
|
1347
1386
|
</div>`
|
|
1348
1387
|
}
|
|
1349
1388
|
|
|
1389
|
+
<h2>Completion hook</h2>
|
|
1390
|
+
<div class="actions" id="hook-box">
|
|
1391
|
+
<button id="run-hook">Run it for this archive</button>
|
|
1392
|
+
<span class="sub" id="hook-note"></span>
|
|
1393
|
+
</div>
|
|
1394
|
+
<div class="sub" style="margin-bottom:1rem">
|
|
1395
|
+
Runs the configured <code>onComplete</code> command again for this
|
|
1396
|
+
archive. It normally runs once, when the download finishes, and is
|
|
1397
|
+
recorded as having run so a build taking hours is not started over
|
|
1398
|
+
every minute β which also means a hook that failed for a reason
|
|
1399
|
+
since fixed will not retry on its own. What it does is decided in
|
|
1400
|
+
the config file; this only decides when.
|
|
1401
|
+
</div>
|
|
1402
|
+
|
|
1350
1403
|
<h2>Mode</h2>
|
|
1351
1404
|
<div class="actions" id="mode-box">
|
|
1352
1405
|
<button id="mode-mirror">Switch to mirror</button>
|
|
@@ -1409,6 +1462,30 @@
|
|
|
1409
1462
|
const loaded = new Set(['general']);
|
|
1410
1463
|
|
|
1411
1464
|
/** Shows one tab, loading it the first time. */
|
|
1465
|
+
const runHook = $('run-hook');
|
|
1466
|
+
if (runHook) {
|
|
1467
|
+
runHook.onclick = async () => {
|
|
1468
|
+
const note = $('hook-note');
|
|
1469
|
+
runHook.disabled = true;
|
|
1470
|
+
note.textContent = 'startingβ¦';
|
|
1471
|
+
try {
|
|
1472
|
+
const result = await api(
|
|
1473
|
+
`/api/torrents/${infoHash}/hooks/complete`,
|
|
1474
|
+
{ method: 'POST' },
|
|
1475
|
+
);
|
|
1476
|
+
// Started, not finished: the command may be a planet build, and
|
|
1477
|
+
// nothing here waits for one.
|
|
1478
|
+
note.textContent = `started ${result.command ?? 'the hook'}`;
|
|
1479
|
+
toast('completion hook started');
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
note.textContent = error.message;
|
|
1482
|
+
toast(error.message);
|
|
1483
|
+
} finally {
|
|
1484
|
+
runHook.disabled = false;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1412
1489
|
const showPane = (name) => {
|
|
1413
1490
|
activeTab = name;
|
|
1414
1491
|
for (const other of panel.querySelectorAll('.tabs button')) {
|
|
@@ -2324,8 +2401,23 @@
|
|
|
2324
2401
|
* @returns {void}
|
|
2325
2402
|
*/
|
|
2326
2403
|
function renderRowEditor(spec) {
|
|
2327
|
-
const {
|
|
2328
|
-
|
|
2404
|
+
const {
|
|
2405
|
+
into,
|
|
2406
|
+
key,
|
|
2407
|
+
title,
|
|
2408
|
+
blurb,
|
|
2409
|
+
columns,
|
|
2410
|
+
rows,
|
|
2411
|
+
restart,
|
|
2412
|
+
preview,
|
|
2413
|
+
peerPreview,
|
|
2414
|
+
footnote,
|
|
2415
|
+
// Where to POST a "do it now". A schedule is a statement about
|
|
2416
|
+
// ordinary operation, and setting one up is not ordinary operation:
|
|
2417
|
+
// waiting six hours to learn whether a URL template is right is how
|
|
2418
|
+
// a typo survives a working day.
|
|
2419
|
+
checkNow,
|
|
2420
|
+
} = spec;
|
|
2329
2421
|
rowEditorColumns[key] = columns;
|
|
2330
2422
|
// Kept so a save can put back what this editor never showed. An entry
|
|
2331
2423
|
// holds more than there are columns for it β a watch folder's
|
|
@@ -2404,6 +2496,11 @@
|
|
|
2404
2496
|
</div>
|
|
2405
2497
|
<div style="margin-top:0.5rem">
|
|
2406
2498
|
<button type="button" data-act="add">Add row</button>
|
|
2499
|
+
${
|
|
2500
|
+
checkNow
|
|
2501
|
+
? '<button type="button" data-act="check-now">Check now</button>'
|
|
2502
|
+
: ''
|
|
2503
|
+
}
|
|
2407
2504
|
<span class="sub" data-role="note"></span>
|
|
2408
2505
|
</div>
|
|
2409
2506
|
${footnote ? `<div class="sub" style="margin-top:0.6rem">${escapeHtml(footnote)}</div>` : ''}`;
|
|
@@ -2436,6 +2533,29 @@
|
|
|
2436
2533
|
tbody.insertAdjacentHTML('beforeend', rowHtml());
|
|
2437
2534
|
return;
|
|
2438
2535
|
}
|
|
2536
|
+
|
|
2537
|
+
if (action === 'check-now') {
|
|
2538
|
+
// Against what is saved, not what is on screen. An unsaved row is
|
|
2539
|
+
// not something the node knows about, and pretending otherwise
|
|
2540
|
+
// would report on a source that does not exist yet.
|
|
2541
|
+
const button = event.target;
|
|
2542
|
+
button.disabled = true;
|
|
2543
|
+
note.textContent = 'checkingβ¦';
|
|
2544
|
+
try {
|
|
2545
|
+
const result = await api(checkNow, { method: 'POST' });
|
|
2546
|
+
const count = result.added ?? result.taken ?? 0;
|
|
2547
|
+
note.textContent =
|
|
2548
|
+
count > 0
|
|
2549
|
+
? `took ${count} β save first if you have unsaved changes`
|
|
2550
|
+
: 'nothing new';
|
|
2551
|
+
if (count > 0) refresh();
|
|
2552
|
+
} catch (error) {
|
|
2553
|
+
note.textContent = error.message;
|
|
2554
|
+
} finally {
|
|
2555
|
+
button.disabled = false;
|
|
2556
|
+
}
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2439
2559
|
if (action === 'drop') {
|
|
2440
2560
|
// Never leave it with nothing to type into.
|
|
2441
2561
|
if (tbody.children.length === 1) row.querySelectorAll('input').forEach((i) => (i.value = ''));
|
|
@@ -3280,6 +3400,7 @@
|
|
|
3280
3400
|
},
|
|
3281
3401
|
],
|
|
3282
3402
|
rows: config.sources ?? [],
|
|
3403
|
+
checkNow: '/api/sources/check',
|
|
3283
3404
|
preview: true,
|
|
3284
3405
|
footnote:
|
|
3285
3406
|
'Tokens combine however the upstream spells its filenames β ' +
|
|
@@ -3411,6 +3532,7 @@
|
|
|
3411
3532
|
},
|
|
3412
3533
|
],
|
|
3413
3534
|
rows: config.subscriptions ?? [],
|
|
3535
|
+
checkNow: '/api/subscriptions/refresh',
|
|
3414
3536
|
peerPreview: true,
|
|
3415
3537
|
footnote:
|
|
3416
3538
|
'Take as decides the disk this costs: cache joins the swarm and ' +
|