Skip to content

Commit 3ab51a0

Browse files
cansofgreaseclaude
andcommitted
Spin the Export and Import buttons while they are working
Both buttons went dead quiet once clicked. An export of the whole database can take a while to build and showed nothing at all until the file appeared; an import says "Importing" once and never says anything again, so a restore that takes minutes looked exactly like one that had hung. In both cases the button still looked untouched and invited a second click - which on an import means sending the whole file again into a half-applied restore. They now do what the speedtest RUN button already does: the label is replaced by a spinner for as long as the work runs, and comes back whatever the outcome, including the failures. The label keeps its space while it is hidden, so the button does not resize under the pointer mid-click, and a screen reader is told the button is busy since it cannot see the swap. Export also refuses a second click while it is running, which it never did before. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d2de8ce commit 3ab51a0

2 files changed

Lines changed: 109 additions & 13 deletions

File tree

internal/web/ui/index.html

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,15 @@
11411141
padding:7px 14px;font:inherit;font-weight:600;font-size:12px;cursor:pointer;
11421142
transition:filter .15s,transform .08s;}
11431143
.btn:hover{filter:brightness(1.09);} .btn:active{transform:translateY(1px);} .btn:disabled{opacity:.55;cursor:default;}
1144+
/* Busy state for ordinary buttons, same swap the RUN pill uses: the label goes
1145+
visibility:hidden rather than display:none so the button keeps its own width
1146+
and nothing on the row shifts while the work runs. Opacity stays up - a busy
1147+
button is not a disabled one to look at, even though it is one to click. */
1148+
.btn .btn-busy{display:none;color:currentColor;}
1149+
.btn.busy{position:relative;}
1150+
.btn.busy:disabled{opacity:1;}
1151+
.btn.busy .btn-lbl{visibility:hidden;}
1152+
.btn.busy .btn-busy{display:block;position:absolute;inset:0;margin:auto;}
11441153
/* Speed panel "RUN" button - a matte pill. Keeps the old pill shape and
11451154
footprint. It is a .btn (flat accent fill, like every other button here) - only its
11461155
size, the uppercase label, and the spinner swap are its own. */
@@ -1914,7 +1923,7 @@ <h1><svg class="logo" viewBox="390 0 1824 447" xmlns="http://www.w3.org/2000/svg
19141923
<button class="cat on" type="button" data-cat="downtime">Downtime</button>
19151924
</div>
19161925
</div>
1917-
<button class="btn" id="exportBtn" type="button">Export</button>
1926+
<button class="btn" id="exportBtn" type="button"><span class="btn-lbl">Export</span><span class="btn-busy btn-spin" aria-hidden="true"></span></button>
19181927
</div>
19191928
<span class="muted card-foot">Exports the selected categories as one JSON file. <b>No passwords are exported</b> - not your login, not your iperf3 servers'. Restoring here keeps the iperf3 passwords already saved on this machine; on a different machine you re-enter them. <b>Your webhook and heartbeat URLs are included</b>, and those act as credentials - keep the file private.</span>
19201929
<span class="muted card-foot" id="exportMsg"></span>
@@ -1930,7 +1939,7 @@ <h1><svg class="logo" viewBox="390 0 1824 447" xmlns="http://www.w3.org/2000/svg
19301939
</div>
19311940
</div>
19321941
<div class="bk-actions">
1933-
<button class="btn" id="importBtn" type="button">Import</button>
1942+
<button class="btn" id="importBtn" type="button"><span class="btn-lbl">Import</span><span class="btn-busy btn-spin" aria-hidden="true"></span></button>
19341943
<label class="filebtn" id="importFileBtn"><span class="flabel" id="importFileName">Choose file…</span><input type="file" id="importFile" accept="application/json,.json"></label>
19351944
</div>
19361945
</div>
@@ -7561,13 +7570,29 @@ <h2><span class="drag-handle" title="Drag to reorder" aria-hidden="true"><svg vi
75617570
const c=getCats('exportCats');
75627571
if(!c.length){ $('exportMsg').textContent='Select at least one category to export.'; return; }
75637572
$('exportMsg').textContent='';
7564-
const res=await downloadVia('api/export?'+c.map(x=>x+'=1').join('&'), 'pingularity-export.json');
7573+
btnBusy($('exportBtn'), true);
7574+
let res;
7575+
try{
7576+
res=await downloadVia('api/export?'+c.map(x=>x+'=1').join('&'), 'pingularity-export.json');
7577+
} finally {
7578+
btnBusy($('exportBtn'), false);
7579+
}
75657580
if(res==='toobig'){
75667581
$('exportMsg').textContent='This backup is too large to download in the browser. Fetch it with curl, or stop the service first and copy the database file with its -wal sidecar and pingularity.key - see the docs.';
75677582
} else if(res==='fail'){
75687583
$('exportMsg').textContent='Export failed.';
75697584
}
75707585
});
7586+
// Swap a button's label for a spinner while its work runs, and refuse a second
7587+
// click for the same reason the import handler already disabled itself: a repeat
7588+
// send would upload the whole file again into a half-applied import. aria-busy
7589+
// carries the same state to a screen reader, which cannot see the swap.
7590+
function btnBusy(b, on){
7591+
if(!b) return;
7592+
b.classList.toggle('busy', on);
7593+
b.disabled = on;
7594+
if(on) b.setAttribute('aria-busy','true'); else b.removeAttribute('aria-busy');
7595+
}
75717596
// Transient status via the shared role=status toast (announced to screen readers).
75727597
function flashStatus(msg){ const tb=$('undoToast'); if(!tb) return; tb.textContent=msg; tb.hidden=false; clearTimeout(tb._t); tb._t=setTimeout(()=>{ tb.hidden=true; }, 6000); }
75737598
$('logDownload').addEventListener('click', async e=>{ e.preventDefault();
@@ -7596,7 +7621,7 @@ <h2><span class="drag-handle" title="Drag to reorder" aria-hidden="true"><svg vi
75967621
// A big restore takes minutes, and a second click would send the whole file again
75977622
// into a half-applied import. The button comes back in the finally below, whatever
75987623
// the outcome.
7599-
$('importBtn').disabled=true;
7624+
btnBusy($('importBtn'), true);
76007625
try{
76017626
// The File goes to fetch AS IS. f.text() first would materialise the entire
76027627
// backup as a JS string and then a second UTF-8 copy of it, which a default
@@ -7637,7 +7662,7 @@ <h2><span class="drag-handle" title="Drag to reorder" aria-hidden="true"><svg vi
76377662
} else { msg.textContent='Import failed: '+(raw.trim().slice(0,200)||('HTTP '+r.status)); }
76387663
}
76397664
}catch(e){ msg.textContent='Import failed: '+e.message; }
7640-
finally{ $('importBtn').disabled=false; }
7665+
finally{ btnBusy($('importBtn'), false); }
76417666
});
76427667
// Outage detection only pauses when LATENCY probing is scheduled, so warn (here
76437668
// and on the Alerts tab) when that's on AND a webhook is set - alerts would miss

internal/web/ui/ui.test.mjs

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,14 +2112,23 @@ function driveImport({ ok = true, resp = {}, fetchFails = false } = {}) {
21122112
let handler = null;
21132113
const file = { name: 'backup.json', size: 290 * 1024 * 1024, reads: 0,
21142114
async text() { this.reads++; return '{}'; } };
2115-
const btn = { disabled: false, addEventListener: (_ev, fn) => { handler = fn; } };
2115+
// A real classList, so the shipped btnBusy runs unmodified and the spinner swap
2116+
// is observed rather than stubbed away.
2117+
const classes = new Set();
2118+
const btn = { disabled: false, addEventListener: (_ev, fn) => { handler = fn; },
2119+
classList: { toggle: (c, on) => { on ? classes.add(c) : classes.delete(c); },
2120+
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
2121+
setAttribute: (k, v) => { btn._attrs = Object.assign(btn._attrs || {}, { [k]: v }); },
2122+
removeAttribute: k => { if (btn._attrs) delete btn._attrs[k]; },
2123+
_attrs: {}, classes };
21162124
const msg = { textContent: '' };
21172125
const els = { importBtn: btn, importFile: { files: [file] }, importMsg: msg,
21182126
outagesSection: { style: { display: 'none' } } };
21192127
const sent = { body: undefined, headers: null, disabledMidUpload: null };
21202128
const fetchStub = async (_url, opt) => {
21212129
sent.body = opt.body; sent.headers = opt.headers;
21222130
sent.disabledMidUpload = btn.disabled; // a second click here would upload twice
2131+
sent.busyMidUpload = classes.has('busy'); // and the label is a spinner while it runs
21232132
if (fetchFails) throw new Error('network went away');
21242133
return { ok, status: ok ? 200 : 500, json: async () => resp,
21252134
text: async () => JSON.stringify(resp) };
@@ -2128,10 +2137,13 @@ function driveImport({ ok = true, resp = {}, fetchFails = false } = {}) {
21282137
const register = new Function('$', 'getCats', 'fetch', 'confirm', 'loadSettings',
21292138
'loadAccess', 'formSnapshot', 'refreshStatus', 'refreshChart', 'refreshSpeedChart',
21302139
'refreshHeatmap', 'loadOutages',
2131-
'let savedBody = null;\n' + extract("$('importBtn').addEventListener('click'") + ');');
2140+
'let savedBody = null;\n' + extract('function btnBusy(') + '\n' +
2141+
extract("$('importBtn').addEventListener('click'") + ');');
21322142
register(id => els[id], () => ['pings'], fetchStub, () => true, noop, noop,
21332143
() => '', () => {}, () => {}, () => {}, () => {}, noop);
2134-
return handler().then(() => ({ file, btn, msg, sent }));
2144+
sent.busyMidUpload = null;
2145+
const origFetch = fetchStub;
2146+
return handler().then(() => ({ file, btn, msg, sent, classes }));
21352147
}
21362148

21372149
test('import hands the file to fetch instead of reading it into memory', async () => {
@@ -2147,6 +2159,22 @@ test('import hands the file to fetch instead of reading it into memory', async (
21472159
'the explicit Content-Type must survive - the File cannot be relied on to carry one');
21482160
});
21492161

2162+
// The spinner is the only feedback a long import gives: the message line says
2163+
// "Importing" once and never changes again, so a restore that takes minutes looks
2164+
// identical to one that hung. It has to go up for the whole upload and come down
2165+
// on every exit, including the ones that threw.
2166+
test('import swaps its label for a spinner while the upload runs, and puts it back', async () => {
2167+
const done = await driveImport();
2168+
assert.equal(done.sent.busyMidUpload, true,
2169+
'a multi-minute restore has to show it is working, or the operator cannot tell it from a hang');
2170+
assert.equal(done.classes.has('busy'), false,
2171+
'and the label has to come back on success, or the button reads as busy forever');
2172+
const refused = await driveImport({ ok: false, resp: { error: 'nope' } });
2173+
assert.equal(refused.classes.has('busy'), false, 'a refused import must not leave the spinner up');
2174+
const broken = await driveImport({ fetchFails: true });
2175+
assert.equal(broken.classes.has('busy'), false, 'nor must a network failure mid-upload');
2176+
});
2177+
21502178
test('import will not take a second click while the first is still uploading', async () => {
21512179
const done = await driveImport();
21522180
assert.equal(done.sent.disabledMidUpload, true,
@@ -3596,6 +3624,9 @@ function driveDownloads({ status = 200 } = {}) {
35963624
const seen = [];
35973625
const rawFetch = async (url, init) => {
35983626
seen.push({ url: String(url), headers: new Headers((init && init.headers) || undefined) });
3627+
// Sampled on the wire: the spinner has to be up WHILE the export is fetching,
3628+
// which is the only window a stuck-looking button would be observed in.
3629+
if (els.exportBtn) out.busyOnWire = els.exportBtn.classes.has('busy');
35993630
return {
36003631
ok: status >= 200 && status < 300, status,
36013632
headers: new Headers({ 'Content-Disposition': 'attachment; filename="from-server.txt"' }),
@@ -3611,11 +3642,18 @@ function driveDownloads({ status = 200 } = {}) {
36113642
createElement: () => { const a = { clicked: false, click() { this.clicked = true; }, remove() {} }; anchors.push(a); return a; },
36123643
};
36133644
const els = {}, handlers = {};
3614-
const $ = id => els[id] || (els[id] = {
3615-
textContent: '',
3616-
addEventListener: (ev, fn) => { handlers[id + ':' + ev] = fn; },
3617-
});
3618-
const out = { seen, anchors, handlers, els, flashed: [], loginShown: 0 };
3645+
const out = { busyOnWire: null };
3646+
const $ = id => els[id] || (els[id] = (() => {
3647+
const classes = new Set();
3648+
return {
3649+
textContent: '', classes, disabled: false,
3650+
addEventListener: (ev, fn) => { handlers[id + ':' + ev] = fn; },
3651+
classList: { toggle: (c, on) => { on ? classes.add(c) : classes.delete(c); },
3652+
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
3653+
setAttribute() {}, removeAttribute() {},
3654+
};
3655+
})());
3656+
Object.assign(out, { seen, anchors, handlers, els, flashed: [], loginShown: 0 });
36193657
const src =
36203658
soleAnchor('const _fetch=window.fetch.bind(window);',
36213659
'the raw pre-wrapper handle the login POST uses is gone, and the page has no unmarked escape hatch') + '\n' +
@@ -3637,6 +3675,7 @@ function driveDownloads({ status = 200 } = {}) {
36373675
'const fetch=window.fetch;\n' +
36383676
script.match(/const downloadCapBytes = [^;]*;/)[0] + '\n' +
36393677
sliceBetween('async function downloadVia(', "$('exportBtn').addEventListener('click'") + '\n' +
3678+
extract('function btnBusy(') + '\n' +
36403679
extract(soleAnchor("$('exportBtn').addEventListener('click'")) + ');\n' +
36413680
extract(soleAnchor("$('logDownload').addEventListener('click'")) + ');\n' +
36423681
extract(soleAnchor("$('csvDownload').addEventListener('click'")) + ');\n' +
@@ -3652,6 +3691,38 @@ function driveDownloads({ status = 200 } = {}) {
36523691
return out;
36533692
}
36543693

3694+
// The swap is half JS and half stylesheet: the class the handlers toggle does
3695+
// nothing on its own. These pin the two rules that carry the meaning, and the
3696+
// one that keeps the button from resizing under the operator.
3697+
test('the busy class actually hides the label and shows the spinner', () => {
3698+
const rule = sel => {
3699+
const m = html.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\{([^}]*)\\}'));
3700+
assert.ok(m, `${sel} is not in the stylesheet, so the busy state renders as nothing at all`);
3701+
return m[1];
3702+
};
3703+
assert.match(rule('.btn.busy .btn-lbl'), /visibility:hidden/,
3704+
'the label has to stay in the layout while it is hidden - display:none would shrink the button ' +
3705+
'mid-click and shift the row under the pointer');
3706+
assert.match(rule('.btn.busy .btn-busy'), /display:block/,
3707+
'the spinner is display:none by default, so without this the busy button shows nothing at all');
3708+
assert.match(rule('.btn .btn-busy'), /display:none/,
3709+
'an idle button must not show a spinner beside its label');
3710+
});
3711+
3712+
// A default export is the whole database and can take a while to build, during
3713+
// which downloadVia shows nothing at all - no progress, no message. Without the
3714+
// swap the button looks untouched and invites a second click that starts the
3715+
// whole export again.
3716+
test('export swaps its label for a spinner while the file is being fetched, and puts it back', async () => {
3717+
const d = driveDownloads();
3718+
await d.handlers['exportBtn:click']({ preventDefault() {} });
3719+
assert.equal(d.busyOnWire, true,
3720+
'the export button looked idle while the server was building the file, so a second click starts a second export');
3721+
assert.equal(d.els.exportBtn.classes.has('busy'), false,
3722+
'and the label has to come back once the file is handed over');
3723+
assert.equal(d.els.exportBtn.disabled, false, 'a finished export must leave a usable button');
3724+
});
3725+
36553726
test('requests through the fetch wrapper are marked, or the browser stacks its own password box on top of the login overlay', async () => {
36563727
const d = driveDownloads();
36573728
await d.markedFetch('api/status');

0 commit comments

Comments
 (0)