fix: stop processing controls on Console sign-out and preserve layout
This commit is contained in:
1 parent
cdb4f633dd
commit
eafcbd3e7d
4 files changed
+91
-16
No files matched your search
@@ -5,3 +5,16 @@
|
||||
.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-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; } }
|
||||
@@ -6,7 +6,19 @@
|
||||
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 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() {
|
||||
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
|
||||
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
|
||||
@@ -14,31 +26,33 @@
|
||||
: snapshot?.halted === false ? 'Processing is enabled.' : 'Processing state unavailable.';
|
||||
banner.hidden = !halted;
|
||||
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.disabled = !canWrite || busy || !valid;
|
||||
resume.disabled = out() || !canWrite || busy || !valid;
|
||||
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() {
|
||||
if (busy) return;
|
||||
if (busy || out()) return;
|
||||
const current = ++sequence;
|
||||
try {
|
||||
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()]);
|
||||
if (current !== sequence) return;
|
||||
const [config, state] = await Promise.all([responseBody(configResponse), responseBody(stateResponse)]);
|
||||
if (current !== sequence || out()) return;
|
||||
canWrite = configResponse.ok && config.send === true;
|
||||
snapshot = stateResponse.ok && [true, false, null].includes(state.halted) ? state : null;
|
||||
} catch {
|
||||
if (current !== sequence) return;
|
||||
if (current !== sequence || out()) return;
|
||||
canWrite = false; snapshot = null;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
async function write(action) {
|
||||
if (!canWrite || busy) return;
|
||||
if (out() || !canWrite || busy) return;
|
||||
if (action === 'resume' && (!snapshot?.halted || !UUID.test(snapshot.generation) ||
|
||||
!window.confirm('Resume processing for this instance? New work may launch again.'))) return;
|
||||
const args = action === 'halt' ? {} : { generation: snapshot.generation };
|
||||
@@ -47,22 +61,30 @@
|
||||
const response = await fetch('/api/processing/' + action, {
|
||||
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 (typeof result.halted !== 'boolean' || result.version !== 1 || !UUID.test(result.generation)) throw Error();
|
||||
snapshot = result;
|
||||
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.';
|
||||
} 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 {
|
||||
busy = false; draw(); await refresh();
|
||||
}
|
||||
}
|
||||
halt.addEventListener('click', () => write('halt'));
|
||||
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('focus', refresh);
|
||||
setInterval(refresh, 5000);
|
||||
refresh();
|
||||
// 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();
|
||||
}, { once: true });
|
||||
})();
|
||||
@@ -10,9 +10,20 @@ async function setup(t, readonly = false) {
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
|
||||
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.processing={version:1,generation:'${GEN}',halted:${readonly},quiescent:true,remaining:0,stopped:0};
|
||||
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(!String(url).startsWith('/api/processing'))return real(url,init);
|
||||
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('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,12 +6,15 @@ const source = new URL('../src/public/processing.js', import.meta.url);
|
||||
const GEN = '12345678-1234-4234-8234-123456789abc';
|
||||
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 } = {}) {
|
||||
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'])
|
||||
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 };
|
||||
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; } },
|
||||
setInterval: () => ({ unref() {} }), clearInterval() {},
|
||||
fetch: async (url, options = {}) => {
|
||||
@@ -25,6 +28,7 @@ async function fixture({ send = true, halted = false, unknown = false, partial =
|
||||
return { ok: true, json: async () => ({ ...state }) };
|
||||
},
|
||||
});
|
||||
events.get('DOMContentLoaded')();
|
||||
await flush();
|
||||
const click = async id => { await nodes.get(id).listeners.get('click')(); await flush(); };
|
||||
return { nodes, requests, events, state, click, confirmations: () => confirmations };
|
||||
|
||||
Reference in new issue
Block a user