Pithos 0.3.7: optional automatic upload of leftovers
s3d only sends full ~40 MB blocks to Sia, so a handful of small files can stay in the local buffer forever (seen: 63 objects, 6 MiB, nothing on Sia). An opt-in setting now flushes once the pending set has not changed for N minutes, judged from s3d's upload stats so uploads from any S3 client count. Failed flushes back off up to an hour instead of hammering a broken indexer.
This commit is contained in:
parent
08da2d897e
commit
76496fd0ee
6 changed files with 233 additions and 3 deletions
81
core/autoflush.js
Normal file
81
core/autoflush.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// Optional automatic flush of leftovers. s3d packs uploads into full slabs
|
||||||
|
// (about 40 MB) before sending them to Sia, so a small batch can sit in the
|
||||||
|
// local buffer indefinitely. When the user opts in, this watches s3d's upload
|
||||||
|
// stats and flushes once the pending set has stopped changing for N minutes,
|
||||||
|
// i.e. nobody is still uploading into it.
|
||||||
|
//
|
||||||
|
// The watcher only sees stats, so it also catches uploads made by other S3
|
||||||
|
// clients, not just the ones that went through Pithos.
|
||||||
|
|
||||||
|
export const DEFAULT_MINUTES = 10;
|
||||||
|
export const MIN_MINUTES = 1;
|
||||||
|
export const MAX_MINUTES = 24 * 60;
|
||||||
|
|
||||||
|
export function clampMinutes(m) {
|
||||||
|
const n = Math.round(Number(m));
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_MINUTES;
|
||||||
|
return Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings: () => ({ enabled, minutes }); stats: async () => /stats/uploads;
|
||||||
|
// flush: async () => void; log: (line) => void.
|
||||||
|
export function createAutoFlush({ settings, stats, flush, log = () => {}, now = Date.now }) {
|
||||||
|
let signature = null; // what was pending last time we looked
|
||||||
|
let quietSince = 0;
|
||||||
|
let flushing = false;
|
||||||
|
let lastFlushAt = null;
|
||||||
|
let lastError = null;
|
||||||
|
let failures = 0; // in a row; each one doubles the wait, up to an hour
|
||||||
|
|
||||||
|
function reset() { signature = null; quietSince = 0; }
|
||||||
|
|
||||||
|
function waitMs(minutes) {
|
||||||
|
const base = clampMinutes(minutes) * 60_000;
|
||||||
|
return failures ? Math.max(base, Math.min(60 * 60_000, base * 2 ** failures)) : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const { enabled, minutes } = settings();
|
||||||
|
if (!enabled || flushing) { if (!enabled) reset(); return; }
|
||||||
|
let x;
|
||||||
|
try { x = await stats(); } catch { reset(); return; } // s3d not reachable
|
||||||
|
if (!x.pendingObjects) { reset(); return; }
|
||||||
|
const sig = `${x.pendingObjects}:${x.pendingSize}`;
|
||||||
|
if (sig !== signature) { signature = sig; quietSince = now(); return; }
|
||||||
|
if (now() - quietSince < waitMs(minutes)) return;
|
||||||
|
flushing = true;
|
||||||
|
log(`uploading ${x.pendingObjects} leftover object(s) to Sia after ${clampMinutes(minutes)} min with no new uploads`);
|
||||||
|
try {
|
||||||
|
await flush();
|
||||||
|
lastFlushAt = now();
|
||||||
|
lastError = null;
|
||||||
|
failures = 0;
|
||||||
|
log('leftovers are on Sia');
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e.message;
|
||||||
|
failures++;
|
||||||
|
log(`automatic flush failed: ${e.message}`);
|
||||||
|
quietSince = now(); // a broken indexer is retried less and less often
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tick,
|
||||||
|
status() {
|
||||||
|
const { enabled, minutes } = settings();
|
||||||
|
const m = clampMinutes(minutes);
|
||||||
|
return {
|
||||||
|
enabled: !!enabled,
|
||||||
|
minutes: m,
|
||||||
|
flushing,
|
||||||
|
lastFlushAt,
|
||||||
|
lastError,
|
||||||
|
// When the next automatic flush happens if nothing new arrives.
|
||||||
|
dueAt: enabled && signature && !flushing ? quietSince + waitMs(m) : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import * as admin from './admin.js';
|
||||||
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
|
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
|
||||||
import * as accounts from './accounts.js';
|
import * as accounts from './accounts.js';
|
||||||
import { readPrefs, writePrefs } from './prefs.js';
|
import { readPrefs, writePrefs } from './prefs.js';
|
||||||
|
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const UI_DIR = path.join(HERE, '..', 'ui');
|
const UI_DIR = path.join(HERE, '..', 'ui');
|
||||||
|
|
@ -296,6 +297,27 @@ export async function createPithos(opts = {}) {
|
||||||
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
|
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
|
||||||
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
|
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
|
||||||
|
|
||||||
|
const autoFlush = createAutoFlush({
|
||||||
|
settings: () => {
|
||||||
|
const p = readPrefs(configFile);
|
||||||
|
return { enabled: p.autoFlush === true, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES };
|
||||||
|
},
|
||||||
|
// Only an s3d that is up can be asked; a stopped one just means no flush.
|
||||||
|
stats: () => (daemon.state === 'starting' || daemon.state === 'stopping'
|
||||||
|
? Promise.reject(new Error('busy')) : admin.uploadStats(cfg())),
|
||||||
|
flush: () => admin.flush(cfg()),
|
||||||
|
log: (line) => daemon.pushLog('sys', line),
|
||||||
|
});
|
||||||
|
route('GET', '/api/autoflush', () => autoFlush.status());
|
||||||
|
route('PUT', '/api/autoflush', async (req) => {
|
||||||
|
const body = await jsonBody(req);
|
||||||
|
const patch = {};
|
||||||
|
if (body.enabled !== undefined) patch.autoFlush = body.enabled === true;
|
||||||
|
if (body.minutes !== undefined) patch.autoFlushMinutes = clampMinutes(body.minutes);
|
||||||
|
writePrefs(configFile, patch);
|
||||||
|
return autoFlush.status();
|
||||||
|
});
|
||||||
|
|
||||||
// Buckets & objects, acting as one s3d user.
|
// Buckets & objects, acting as one s3d user.
|
||||||
route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets());
|
route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets());
|
||||||
route('POST', '/api/s3/:user/buckets', async (req, url, p) => {
|
route('POST', '/api/s3/:user/buckets', async (req, url, p) => {
|
||||||
|
|
@ -532,6 +554,8 @@ export async function createPithos(opts = {}) {
|
||||||
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
|
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
|
||||||
});
|
});
|
||||||
setTimeout(() => autostart('it was running before'), 500).unref?.();
|
setTimeout(() => autostart('it was running before'), 500).unref?.();
|
||||||
|
const autoFlushTimer = setInterval(() => { autoFlush.tick(); }, 30_000);
|
||||||
|
autoFlushTimer.unref?.();
|
||||||
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
||||||
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
|
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
|
||||||
|
|
||||||
|
|
@ -565,6 +589,7 @@ export async function createPithos(opts = {}) {
|
||||||
return `${baseUrl}dl/${id}`;
|
return `${baseUrl}dl/${id}`;
|
||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
|
clearInterval(autoFlushTimer);
|
||||||
login.cancel();
|
login.cancel();
|
||||||
for (const s of subscribers) s.end();
|
for (const s of subscribers) s.end();
|
||||||
await daemon.stop();
|
await daemon.stop();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pithos",
|
"name": "pithos",
|
||||||
"version": "0.3.6",
|
"version": "0.3.7",
|
||||||
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
88
test/autoflush.test.js
Normal file
88
test/autoflush.test.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// The leftover-flush watcher, driven with a fake clock and fake s3d stats.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createAutoFlush, clampMinutes } from '../core/autoflush.js';
|
||||||
|
|
||||||
|
function rig({ enabled = true, minutes = 5 } = {}) {
|
||||||
|
const r = { t: 0, pending: { pendingObjects: 0, pendingSize: 0 }, flushes: 0, fail: null, prefs: { enabled, minutes }, down: false };
|
||||||
|
r.af = createAutoFlush({
|
||||||
|
settings: () => r.prefs,
|
||||||
|
stats: async () => { if (r.down) throw new Error('refused'); return { ...r.pending }; },
|
||||||
|
flush: async () => {
|
||||||
|
if (r.fail) throw new Error(r.fail);
|
||||||
|
r.flushes++;
|
||||||
|
r.pending = { pendingObjects: 0, pendingSize: 0 };
|
||||||
|
},
|
||||||
|
now: () => r.t,
|
||||||
|
});
|
||||||
|
r.at = async (min) => { r.t = min * 60_000; await r.af.tick(); };
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('flushes once the pending set has been unchanged for N minutes', async () => {
|
||||||
|
const r = rig({ minutes: 5 });
|
||||||
|
r.pending = { pendingObjects: 3, pendingSize: 1000 };
|
||||||
|
await r.at(0);
|
||||||
|
await r.at(4.9);
|
||||||
|
assert.equal(r.flushes, 0);
|
||||||
|
assert.equal(r.af.status().dueAt, 5 * 60_000);
|
||||||
|
await r.at(5);
|
||||||
|
assert.equal(r.flushes, 1);
|
||||||
|
assert.equal(r.af.status().lastFlushAt, 5 * 60_000);
|
||||||
|
await r.at(20);
|
||||||
|
assert.equal(r.flushes, 1, 'nothing pending, nothing to flush');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new upload restarts the quiet period', async () => {
|
||||||
|
const r = rig({ minutes: 5 });
|
||||||
|
r.pending = { pendingObjects: 1, pendingSize: 10 };
|
||||||
|
await r.at(0);
|
||||||
|
await r.at(4);
|
||||||
|
r.pending = { pendingObjects: 2, pendingSize: 20 };
|
||||||
|
await r.at(4.5);
|
||||||
|
await r.at(9);
|
||||||
|
assert.equal(r.flushes, 0);
|
||||||
|
await r.at(9.5);
|
||||||
|
assert.equal(r.flushes, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off means never, and s3d being down is not an error', async () => {
|
||||||
|
const r = rig({ enabled: false });
|
||||||
|
r.pending = { pendingObjects: 5, pendingSize: 50 };
|
||||||
|
for (const m of [0, 10, 100]) await r.at(m);
|
||||||
|
assert.equal(r.flushes, 0);
|
||||||
|
assert.equal(r.af.status().dueAt, null);
|
||||||
|
r.prefs.enabled = true;
|
||||||
|
r.down = true;
|
||||||
|
for (const m of [101, 200]) await r.at(m);
|
||||||
|
assert.equal(r.flushes, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed flushes back off, doubling up to an hour', async () => {
|
||||||
|
const r = rig({ minutes: 2 });
|
||||||
|
r.pending = { pendingObjects: 1, pendingSize: 1 };
|
||||||
|
r.fail = 'indexer unreachable';
|
||||||
|
await r.at(0);
|
||||||
|
await r.at(2); // fails; next try 4 min later
|
||||||
|
assert.equal(r.af.status().lastError, 'indexer unreachable');
|
||||||
|
assert.equal(r.af.status().dueAt, 6 * 60_000);
|
||||||
|
await r.at(6); // fails again; next try 8 min later
|
||||||
|
assert.equal(r.af.status().dueAt, 14 * 60_000);
|
||||||
|
for (let i = 0; i < 6; i++) await r.at(r.af.status().dueAt / 60_000);
|
||||||
|
assert.equal(r.af.status().dueAt - r.t, 60 * 60_000, 'capped at an hour');
|
||||||
|
r.fail = null;
|
||||||
|
await r.at(r.af.status().dueAt / 60_000);
|
||||||
|
assert.equal(r.flushes, 1);
|
||||||
|
assert.equal(r.af.status().lastError, null);
|
||||||
|
r.pending = { pendingObjects: 1, pendingSize: 2 };
|
||||||
|
await r.at(r.t / 60_000);
|
||||||
|
assert.equal(r.af.status().dueAt - r.t, 2 * 60_000, 'a success resets the backoff');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minutes are clamped to a sane range', () => {
|
||||||
|
assert.equal(clampMinutes(0), 1);
|
||||||
|
assert.equal(clampMinutes(99999), 1440);
|
||||||
|
assert.equal(clampMinutes('abc'), 10);
|
||||||
|
assert.equal(clampMinutes('7'), 7);
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pithos",
|
"id": "pithos",
|
||||||
"name": "Pithos",
|
"name": "Pithos",
|
||||||
"version": "0.3.6",
|
"version": "0.3.7",
|
||||||
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||||
|
|
|
||||||
38
ui/app.js
38
ui/app.js
|
|
@ -253,6 +253,7 @@ function overview(main) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const x = await api('GET', '/api/stats');
|
const x = await api('GET', '/api/stats');
|
||||||
|
const af = await api('GET', '/api/autoflush').catch(() => null);
|
||||||
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
|
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
|
||||||
put(statsCard,
|
put(statsCard,
|
||||||
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
|
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
|
||||||
|
|
@ -266,12 +267,24 @@ function overview(main) {
|
||||||
stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0),
|
stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0),
|
||||||
),
|
),
|
||||||
h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'),
|
h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'),
|
||||||
|
af && h('p', { class: 'small muted', style: 'margin:6px 0 0' }, autoFlushLine(af, x), ' ', h('a', { href: '#/settings' }, af.enabled ? 'Change' : 'Turn on')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoFlushLine(af, x) {
|
||||||
|
if (!af.enabled) return 'Leftovers upload only when a block fills up or you flush.';
|
||||||
|
if (af.flushing) return 'Uploading leftovers now…';
|
||||||
|
if (af.lastError && x.pendingObjects) return `The last automatic upload failed (${af.lastError}). Pithos will try again later.`;
|
||||||
|
if (x.pendingObjects && af.dueAt) {
|
||||||
|
const min = Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000));
|
||||||
|
return `Leftovers upload automatically in about ${min} min if nothing new arrives.`;
|
||||||
|
}
|
||||||
|
return `Leftovers upload automatically after ${af.minutes} min with no new uploads.`;
|
||||||
|
}
|
||||||
|
|
||||||
async function flushNow(ev) {
|
async function flushNow(ev) {
|
||||||
const btn = ev.currentTarget;
|
const btn = ev.currentTarget;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
@ -1385,7 +1398,30 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
|
|
||||||
function settings(main) {
|
function settings(main) {
|
||||||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||||||
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card);
|
const flushCard = h('div', { class: 'card', style: 'margin-top:16px' });
|
||||||
|
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card, flushCard);
|
||||||
|
|
||||||
|
// Kept by Pithos itself (pithos-prefs.json), applies at once, no restart.
|
||||||
|
(async () => {
|
||||||
|
let af;
|
||||||
|
try { af = await api('GET', '/api/autoflush'); } catch (e) { put(flushCard, h('p', { class: 'error' }, e.message)); return; }
|
||||||
|
const on = h('input', { type: 'checkbox', checked: af.enabled });
|
||||||
|
const minutes = h('input', { type: 'number', min: 1, max: 1440, value: af.minutes, style: 'width:6em', disabled: !af.enabled });
|
||||||
|
on.addEventListener('change', () => { minutes.disabled = !on.checked; });
|
||||||
|
put(flushCard,
|
||||||
|
h('h2', {}, 'Leftover uploads'),
|
||||||
|
h('p', { class: 'small muted' }, 's3d packs uploads into blocks of about 40 MB before sending them to Sia, so a few small files can wait in the local buffer indefinitely. Pithos can upload them for you once uploading has stopped.'),
|
||||||
|
h('label', { class: 'check' }, on, 'Upload leftovers automatically'),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'after this many minutes with no new uploads'), minutes,
|
||||||
|
h('small', {}, 'Partly filled blocks use a little more Sia space than full ones. Pithos must be open for this to run.')),
|
||||||
|
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: saveFlush }, 'Save')));
|
||||||
|
async function saveFlush() {
|
||||||
|
try {
|
||||||
|
await api('PUT', '/api/autoflush', { enabled: on.checked, minutes: Number(minutes.value) || 10 });
|
||||||
|
toast(on.checked ? 'Leftovers will upload automatically' : 'Automatic upload is off');
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
['Network', [
|
['Network', [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue