fix: stop processing controls on Console sign-out and preserve layout

This commit is contained in:
rocko committed 2026-10-21 21:04:37 -05:00
1 parent cdb4f633dd
commit eafcbd3e7d
4 files changed
+90 -15

No files matched your search

+13
View File
@@ -5,3 +5,16 @@
.processing-controls button:focus-visible { outline:3px solid currentColor; outline-offset:3px; } .processing-controls button:focus-visible { outline:3px solid currentColor; outline-offset:3px; }
.processing-banner { padding:.75rem 1rem; font-weight:600; border-bottom:2px solid currentColor; } .processing-banner { padding:.75rem 1rem; font-weight:600; border-bottom:2px solid currentColor; }
.processing-controls [hidden], .processing-banner[hidden] { display:none; } .processing-controls [hidden], .processing-banner[hidden] { display:none; }
/* Keep the controls in normal flow. On desktop the frame fills the remaining
viewport rather than adding a full viewport below the controls. Navigation
retains the shell's command-bar/footer bounds independently of this bar. */
@media (min-width:760px) {
body { --processing-nav:240px; display:flex; flex-direction:column; min-height:100vh; }
.processing-controls, .processing-banner { margin-left:var(--processing-nav); }
.frame { flex:1; min-height:0; }
.frame > .content { grid-column:2; }
.frame > .tree { position:fixed; width:var(--processing-nav); }
}
@media (min-width:760px) and (max-width:959px) { body { --processing-nav:200px; } }
@media (min-width:1900px) { body { --processing-nav:280px; } }
@media (min-width:2560px) { body { --processing-nav:320px; } }
+35 -13
View File
@@ -6,7 +6,19 @@
const status = byId('processing-state'), halt = byId('processing-halt'), resume = byId('processing-resume'); const status = byId('processing-state'), halt = byId('processing-halt'), resume = byId('processing-resume');
const unavailable = byId('processing-unavailable'), receipt = byId('processing-receipt'), banner = byId('processing-banner'); const unavailable = byId('processing-unavailable'), receipt = byId('processing-receipt'), banner = byId('processing-banner');
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
let snapshot = null, canWrite = false, busy = false, sequence = 0; const out = () => document.body.classList.contains('signed-out');
const signOut = signIn => {
if (!out()) document.dispatchEvent(signIn
? new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })
: new Event('mosaic-signed-out'));
};
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer;
async function responseBody(response) {
if (response.type === 'opaqueredirect') { signOut(false); return null; }
const body = await response.json();
if (response.status === 401 && body?.error === 'sign-in required') { signOut(true); return null; }
return body;
}
function draw() { function draw() {
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation); const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown'; host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
@@ -14,31 +26,33 @@
: snapshot?.halted === false ? 'Processing is enabled.' : 'Processing state unavailable.'; : snapshot?.halted === false ? 'Processing is enabled.' : 'Processing state unavailable.';
banner.hidden = !halted; banner.hidden = !halted;
banner.textContent = 'Processing is halted for this instance. Reads and human recovery controls remain available.'; banner.textContent = 'Processing is halted for this instance. Reads and human recovery controls remain available.';
halt.disabled = !canWrite || busy; halt.disabled = out() || !canWrite || busy;
resume.hidden = !halted; resume.hidden = !halted;
resume.disabled = !canWrite || busy || !valid; resume.disabled = out() || !canWrite || busy || !valid;
unavailable.hidden = canWrite; unavailable.hidden = canWrite;
unavailable.textContent = 'Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.'; unavailable.textContent = out() ? 'Sign in to use processing controls.'
: 'Processing controls are unavailable here. Use mosaic halt or mosaic resume from the instance CLI.';
} }
async function refresh() { async function refresh() {
if (busy) return; if (busy || out()) return;
const current = ++sequence; const current = ++sequence;
try { try {
const [configResponse, stateResponse] = await Promise.all([ const [configResponse, stateResponse] = await Promise.all([
fetch('/api/config', { cache: 'no-store' }), fetch('/api/processing', { cache: 'no-store' }), fetch('/api/config', { cache: 'no-store', redirect: 'manual' }),
fetch('/api/processing', { cache: 'no-store', redirect: 'manual' }),
]); ]);
const [config, state] = await Promise.all([configResponse.json(), stateResponse.json()]); const [config, state] = await Promise.all([responseBody(configResponse), responseBody(stateResponse)]);
if (current !== sequence) return; if (current !== sequence || out()) return;
canWrite = configResponse.ok && config.send === true; canWrite = configResponse.ok && config.send === true;
snapshot = stateResponse.ok && [true, false, null].includes(state.halted) ? state : null; snapshot = stateResponse.ok && [true, false, null].includes(state.halted) ? state : null;
} catch { } catch {
if (current !== sequence) return; if (current !== sequence || out()) return;
canWrite = false; snapshot = null; canWrite = false; snapshot = null;
} }
draw(); draw();
} }
async function write(action) { async function write(action) {
if (!canWrite || busy) return; if (out() || !canWrite || busy) return;
if (action === 'resume' && (!snapshot?.halted || !UUID.test(snapshot.generation) || if (action === 'resume' && (!snapshot?.halted || !UUID.test(snapshot.generation) ||
!window.confirm('Resume processing for this instance? New work may launch again.'))) return; !window.confirm('Resume processing for this instance? New work may launch again.'))) return;
const args = action === 'halt' ? {} : { generation: snapshot.generation }; const args = action === 'halt' ? {} : { generation: snapshot.generation };
@@ -47,22 +61,30 @@
const response = await fetch('/api/processing/' + action, { const response = await fetch('/api/processing/' + action, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(args), redirect: 'manual', cache: 'no-store', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(args), redirect: 'manual', cache: 'no-store',
}); });
const result = await response.json(); const result = await responseBody(response);
if (out()) return;
if (!response.ok) throw Error(); if (!response.ok) throw Error();
if (typeof result.halted !== 'boolean' || result.version !== 1 || !UUID.test(result.generation)) throw Error(); if (typeof result.halted !== 'boolean' || result.version !== 1 || !UUID.test(result.generation)) throw Error();
snapshot = result; snapshot = result;
receipt.textContent = result.quiescent === false ? 'Processing is halted. Some stops are unconfirmed. Read the current status.' receipt.textContent = result.quiescent === false ? 'Processing is halted. Some stops are unconfirmed. Read the current status.'
: result.halted ? 'Processing halted.' : 'Processing resumed. Future work may launch.'; : result.halted ? 'Processing halted.' : 'Processing resumed. Future work may launch.';
} catch { } catch {
receipt.textContent = 'Outcome unknown. Read processing status before trying again. Nothing was retried.'; if (!out()) receipt.textContent = 'Outcome unknown. Read processing status before trying again. Nothing was retried.';
} finally { } finally {
busy = false; draw(); await refresh(); busy = false; draw(); await refresh();
} }
} }
halt.addEventListener('click', () => write('halt')); halt.addEventListener('click', () => write('halt'));
resume.addEventListener('click', () => write('resume')); resume.addEventListener('click', () => write('resume'));
document.addEventListener('mosaic-signed-out', () => {
clearInterval(timer); sequence++; canWrite = false; snapshot = null; receipt.textContent = ''; draw();
});
window.addEventListener('hashchange', refresh); window.addEventListener('hashchange', refresh);
window.addEventListener('focus', refresh); window.addEventListener('focus', refresh);
setInterval(refresh, 5000); // All deferred modules, including app.js's shared sign-out listener, are ready first.
document.addEventListener('DOMContentLoaded', () => {
if (out()) return;
timer = setInterval(refresh, 5000);
refresh(); refresh();
}, { once: true });
})(); })();
@@ -10,9 +10,20 @@ async function setup(t, readonly = false) {
const b = await browser(); t.after(() => b.close()); const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0; const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0;
window.processingRequests=[];window.processingSignin=null;
const interval=window.setInterval.bind(window),clear=window.clearInterval.bind(window),active=new Set();
window.processingTimers=()=>[...active];
window.setInterval=(fn,ms,...args)=>{const id=interval(fn,ms,...args);if(ms===5000)active.add(id);return id};
window.clearInterval=id=>{active.delete(id);return clear(id)};
window.confirm=()=>{window.confirmed++;return window.confirmResume}; window.confirm=()=>{window.confirmed++;return window.confirmResume};
window.processing={version:1,generation:'${GEN}',halted:${readonly},quiescent:true,remaining:0,stopped:0}; window.processing={version:1,generation:'${GEN}',halted:${readonly},quiescent:true,remaining:0,stopped:0};
window.fetch=async(url,init={})=>{ window.fetch=async(url,init={})=>{
if(url==='/api/config'||String(url).startsWith('/api/processing')){
window.processingRequests.push({path:url,method:init.method||'GET',redirect:init.redirect??null});
if(window.processingSignin?.path===url)return window.processingSignin.kind==='redirect'
? {type:'opaqueredirect',ok:false,status:0,json:async()=>{throw Error('redirect has no body')}}
: new Response(JSON.stringify({error:'sign-in required'}),{status:401});
}
if(url==='/api/config')return new Response(JSON.stringify({send:${!readonly}})); if(url==='/api/config')return new Response(JSON.stringify({send:${!readonly}}));
if(!String(url).startsWith('/api/processing'))return real(url,init); if(!String(url).startsWith('/api/processing'))return real(url,init);
if(init.method==='POST'){ if(init.method==='POST'){
@@ -57,3 +68,28 @@ test('processing browser: Q175 read-only interim keeps both writes disabled and
assert.equal(await b.evaluate('window.writes.length'), 0); assert.equal(await b.evaluate('window.writes.length'), 0);
assert.equal(await b.evaluate('document.querySelector("#processing-banner").hidden'), false); assert.equal(await b.evaluate('document.querySelector("#processing-banner").hidden'), false);
}); });
for (const [name,path,kind] of [
['config redirect','/api/config','redirect'],
['state redirect','/api/processing','redirect'],
['write redirect','/api/processing/halt','redirect'],
['config sign-in required','/api/config','signin'],
['state sign-in required','/api/processing','signin'],
['write sign-in required','/api/processing/halt','signin'],
]) test(`processing browser: sign-out from ${name} blocks later refresh and writes`, async t => {
const { b, wait } = await setup(t);
assert.equal(await b.evaluate('window.processingRequests.every(r=>r.redirect==="manual")'), true);
assert.equal(await b.evaluate('window.processingTimers().length'), 1);
await b.evaluate(`window.processingSignin=${JSON.stringify({path,kind})};true`);
if(path.endsWith('/halt')) await b.evaluate('document.querySelector("#processing-halt").click();true');
else await b.evaluate('window.dispatchEvent(new Event("focus"));true');
await wait('document.body.classList.contains("signed-out")');
assert.equal(await b.evaluate('window.processingTimers().length'), 0, 'sign-out cancels the processing interval');
assert.equal(await b.evaluate('document.querySelector("#processing-halt").disabled'), true);
const count = await b.evaluate('window.processingRequests.length');
await b.evaluate(`window.dispatchEvent(new Event('hashchange'));window.dispatchEvent(new Event('focus'));
document.querySelector('#processing-halt').disabled=false;document.querySelector('#processing-halt').click();true`);
await new Promise(resolve=>setTimeout(resolve,100));
assert.equal(await b.evaluate('window.processingRequests.length'), count, 'no processing GET or POST after shared sign-out');
if(kind==='signin') assert.equal(await b.evaluate(`!!document.querySelector('#signed-out a[href^="/auth/login"]')`), true);
});
+6 -2
View File
@@ -6,12 +6,15 @@ const source = new URL('../src/public/processing.js', import.meta.url);
const GEN = '12345678-1234-4234-8234-123456789abc'; const GEN = '12345678-1234-4234-8234-123456789abc';
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); }; const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); };
async function fixture({ send = true, halted = false, unknown = false, partial = false, confirm = true } = {}) { async function fixture({ send = true, halted = false, unknown = false, partial = false, confirm = true } = {}) {
const nodes = new Map(), requests = [], events = new Map(); let confirmations = 0; const nodes = new Map(), requests = [], events = new Map(), classes = new Set(); let confirmations = 0;
for (const id of ['processing-controls', 'processing-state', 'processing-halt', 'processing-resume', 'processing-unavailable', 'processing-receipt', 'processing-banner']) for (const id of ['processing-controls', 'processing-state', 'processing-halt', 'processing-resume', 'processing-unavailable', 'processing-receipt', 'processing-banner'])
nodes.set(id, { id, hidden: false, disabled: false, textContent: '', dataset: {}, listeners: new Map(), addEventListener(name, fn) { this.listeners.set(name, fn); } }); nodes.set(id, { id, hidden: false, disabled: false, textContent: '', dataset: {}, listeners: new Map(), addEventListener(name, fn) { this.listeners.set(name, fn); } });
const state = { version: 1, generation: GEN, halted, quiescent: !partial, remaining: partial ? 1 : 0, stopped: 0 }; const state = { version: 1, generation: GEN, halted, quiescent: !partial, remaining: partial ? 1 : 0, stopped: 0 };
runInNewContext(readFileSync(source, 'utf8'), { runInNewContext(readFileSync(source, 'utf8'), {
document: { getElementById: id => nodes.get(id) }, document: { getElementById: id => nodes.get(id), body: { classList: { contains: name => classes.has(name) } },
addEventListener: (name, fn) => events.set(name, fn),
dispatchEvent: event => { if (event.type === 'mosaic-signed-out') classes.add('signed-out'); events.get(event.type)?.(event); } },
Event, CustomEvent,
window: { addEventListener: (name, fn) => events.set(name, fn), confirm: () => { confirmations++; return confirm; } }, window: { addEventListener: (name, fn) => events.set(name, fn), confirm: () => { confirmations++; return confirm; } },
setInterval: () => ({ unref() {} }), clearInterval() {}, setInterval: () => ({ unref() {} }), clearInterval() {},
fetch: async (url, options = {}) => { fetch: async (url, options = {}) => {
@@ -25,6 +28,7 @@ async function fixture({ send = true, halted = false, unknown = false, partial =
return { ok: true, json: async () => ({ ...state }) }; return { ok: true, json: async () => ({ ...state }) };
}, },
}); });
events.get('DOMContentLoaded')();
await flush(); await flush();
const click = async id => { await nodes.get(id).listeners.get('click')(); await flush(); }; const click = async id => { await nodes.get(id).listeners.get('click')(); await flush(); };
return { nodes, requests, events, state, click, confirmations: () => confirmations }; return { nodes, requests, events, state, click, confirmations: () => confirmations };