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-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; } }
|
||||||
@@ -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.
|
||||||
refresh();
|
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());
|
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,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 };
|
||||||
|
|||||||
Reference in new issue
Block a user