102 lines
5.5 KiB
JavaScript
102 lines
5.5 KiB
JavaScript
/* Durable instance controls, outside all route-specific views. */
|
|
(() => {
|
|
const byId = id => document.getElementById(id);
|
|
const host = byId('processing-controls');
|
|
if (!host) return;
|
|
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}$/;
|
|
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, drawn;
|
|
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 signature = JSON.stringify([snapshot?.version, snapshot?.generation, snapshot?.halted, snapshot?.stopping, canWrite, busy, out()]);
|
|
if (signature === drawn) {
|
|
document.dispatchEvent(new CustomEvent('mosaic-processing-state', { detail: { halted: snapshot?.halted ?? null } }));
|
|
return;
|
|
}
|
|
drawn = signature;
|
|
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
|
|
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
|
|
status.textContent = halted ? snapshot.stopping ? 'Processing halted. Stopping workloads.' : 'Processing halted.'
|
|
: snapshot?.halted === false ? 'Processing is enabled.' : 'Processing state unavailable.';
|
|
const short = byId('processing-short');
|
|
if (short) short.textContent = halted ? 'Processing stopped' : snapshot?.halted === false ? 'Processing on' : 'Processing unavailable';
|
|
const mobileShort = byId('mobile-processing-link');
|
|
if (mobileShort && short) mobileShort.textContent = short.textContent;
|
|
document.dispatchEvent(new CustomEvent('mosaic-processing-state', { detail: { halted: snapshot?.halted ?? null } }));
|
|
banner.hidden = !halted;
|
|
banner.textContent = 'Processing is halted for this instance. Reads and human recovery controls remain available.';
|
|
halt.disabled = out() || !canWrite || busy;
|
|
resume.hidden = !halted;
|
|
resume.disabled = out() || !canWrite || busy || !valid;
|
|
unavailable.hidden = canWrite;
|
|
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 || out()) return;
|
|
const current = ++sequence;
|
|
try {
|
|
const [configResponse, stateResponse] = await Promise.all([
|
|
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }),
|
|
fetch('/api/processing', { cache: 'no-store', redirect: 'manual' }),
|
|
]);
|
|
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 || out()) return;
|
|
canWrite = false; snapshot = null;
|
|
}
|
|
draw();
|
|
}
|
|
async function write(action) {
|
|
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 };
|
|
busy = true; sequence++; receipt.textContent = 'Request in progress.'; draw();
|
|
try {
|
|
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 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 {
|
|
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);
|
|
// 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 });
|
|
})();
|