From eafcbd3e7d6ef6e259fb59d87a7b0857b42337aa Mon Sep 17 00:00:00 2001 From: Rocko Date: Wed, 21 Oct 2026 20:36:53 -0500 Subject: [PATCH] fix: stop processing controls on Console sign-out and preserve layout --- packages/webui/src/public/processing.css | 13 +++++ packages/webui/src/public/processing.js | 50 +++++++++++++------ .../webui/tests/processing-browser.test.mjs | 36 +++++++++++++ packages/webui/tests/processing-ui.test.mjs | 8 ++- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/packages/webui/src/public/processing.css b/packages/webui/src/public/processing.css index 4988bc16f..2dec797dd 100644 --- a/packages/webui/src/public/processing.css +++ b/packages/webui/src/public/processing.css @@ -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; } } diff --git a/packages/webui/src/public/processing.js b/packages/webui/src/public/processing.js index 618edb54c..4663d6496 100644 --- a/packages/webui/src/public/processing.js +++ b/packages/webui/src/public/processing.js @@ -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 }); })(); diff --git a/packages/webui/tests/processing-browser.test.mjs b/packages/webui/tests/processing-browser.test.mjs index 47d09545c..0ded36a4c 100644 --- a/packages/webui/tests/processing-browser.test.mjs +++ b/packages/webui/tests/processing-browser.test.mjs @@ -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); +}); diff --git a/packages/webui/tests/processing-ui.test.mjs b/packages/webui/tests/processing-ui.test.mjs index a4279fdd8..e2b1ca3c9 100644 --- a/packages/webui/tests/processing-ui.test.mjs +++ b/packages/webui/tests/processing-ui.test.mjs @@ -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 };