Persist job history server-side so the output banner and per-step last-run status survive completion and are visible to any logged-in user on any device

This commit is contained in:
zyphlar
2026-10-16 16:09:34 -07:00
parent 9b8752eef5
commit 96d5b9bac8
2 changed files with 378 additions and 83 deletions
+235 -65
View File
@@ -166,6 +166,29 @@
color: #444;
}
.step-name { display: block; }
/* Last run of this step (for the selected county); click to view its output */
.step-last {
display: block;
margin-top: 2px;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 10px;
color: #888;
text-align: left;
cursor: pointer;
}
.step-last:hover { text-decoration: underline; }
.step-last[hidden] { display: none; }
.step-last.running { color: #1f78b4; }
.step-last.success { color: #2e7d32; }
.step-last.failed { color: #c62828; }
.step-last.cancelled,
.step-last.interrupted { color: #b26a00; }
.step-actions {
display: flex;
align-items: center;
@@ -296,6 +319,27 @@
display: none;
}
/* Banner for the job whose output is shown; persists after it ends */
#jobBanner {
padding: 7px 14px;
font-size: 12px;
flex-shrink: 0;
display: none;
background: #1e2e3a;
color: #70b8e0;
border-bottom: 1px solid #111;
}
#jobBanner .job-title { font-weight: 700; }
#jobBanner .job-meta { display: block; margin-top: 2px; font-size: 11px; opacity: 0.85; }
#jobBanner .job-back {
margin-left: 6px; padding: 0; border: none; background: none;
color: inherit; font: inherit; text-decoration: underline; cursor: pointer;
}
#jobBanner.success { background: #1e3a24; color: #6fcf7f; }
#jobBanner.failed { background: #3a1e1e; color: #e07070; }
#jobBanner.cancelled,
#jobBanner.interrupted { background: #3a301e; color: #e0b070; }
#status.success { background: #1e3a24; color: #6fcf7f; }
#status.error { background: #3a1e1e; color: #e07070; }
#status.info { background: #1e2e3a; color: #70b8e0; }
@@ -507,30 +551,30 @@
<!-- Roads -->
<div class="wf-group">
<div class="wf-title">Roads</div>
<div class="step">
<div class="step" data-script="download-county-roads">
<div class="step-num">1</div>
<div class="step-label">Download county data</div>
<div class="step-label"><span class="step-name">Download county data</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-county-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="download-osm-roads">
<div class="step-num">2</div>
<div class="step-label">Download OSM roads</div>
<div class="step-label"><span class="step-name">Download OSM roads</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-osm-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="convert-roads">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-label"><span class="step-name">Convert to GeoJSON</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('convert-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="diff-roads">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</div>
<div class="step-label"><span class="step-name">Diff vs OSM</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('diff-roads')">Run</button>
</div>
@@ -540,30 +584,30 @@
<!-- Paths (Sumter only) -->
<div class="wf-group" id="paths-group">
<div class="wf-title">Paths</div>
<div class="step" id="paths-step-1">
<div class="step" id="paths-step-1" data-script="download-county-paths">
<div class="step-num">1</div>
<div class="step-label">Upload county paths</div>
<div class="step-label"><span class="step-name">Upload county paths</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions" id="paths-step-1-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step" data-script="download-osm-paths">
<div class="step-num">2</div>
<div class="step-label">Download OSM paths</div>
<div class="step-label"><span class="step-name">Download OSM paths</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions" id="paths-step-2-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step" data-script="convert-paths">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-label"><span class="step-name">Convert to GeoJSON</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions" id="paths-step-3-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step" data-script="diff-paths">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</div>
<div class="step-label"><span class="step-name">Diff vs OSM</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions" id="paths-step-4-actions">
<!-- filled by selectCounty() -->
</div>
@@ -573,30 +617,30 @@
<!-- Addresses -->
<div class="wf-group">
<div class="wf-title">Addresses</div>
<div class="step">
<div class="step" data-script="download-county-addresses">
<div class="step-num">1</div>
<div class="step-label">Download county data</div>
<div class="step-label"><span class="step-name">Download county data</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-county-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="download-osm-addresses">
<div class="step-num">2</div>
<div class="step-label">Download OSM addresses</div>
<div class="step-label"><span class="step-name">Download OSM addresses</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-osm-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="convert-addresses">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-label"><span class="step-name">Convert to GeoJSON</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('convert-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step" data-script="diff-addresses">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</div>
<div class="step-label"><span class="step-name">Diff vs OSM</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('diff-addresses')">Run</button>
<label class="force-label">
@@ -604,9 +648,9 @@
</label>
</div>
</div>
<div class="step">
<div class="step" data-script="generate-city-report">
<div class="step-num">5</div>
<div class="step-label">Generate City Boundaries Report</div>
<div class="step-label"><span class="step-name">Generate City Boundaries Report</span><button type="button" class="step-last" hidden></button></div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('generate-city-report')">Run</button>
</div>
@@ -642,6 +686,7 @@
<span>Output</span>
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
</div>
<div id="jobBanner" role="status" aria-live="polite"></div>
<div id="status"></div>
<div id="sys-stats">
<div class="stat-row">
@@ -670,8 +715,12 @@
const COUNTY_CONFIG = {{ counties_json | safe }};
const IS_AUTHENTICATED = {{ 'true' if current_user else 'false' }};
let activeCounty = COUNTY_CONFIG.length ? COUNTY_CONFIG[0].id : '';
let currentJobId = null;
let logCheckInterval = null;
let jobsData = null; // last /api/jobs response
let viewedJob = null; // job whose output is shown in the console
let followLatest = true; // switch the console to new jobs as they start
let lastSeenLatestId = null;
let jobsTimer = null;
let uploading = false;
function requireLogin() {
showStatus('Not authenticated – please log in.', 'error');
@@ -693,6 +742,8 @@
panel.style.setProperty('--active-color', config ? config.color : '#28a745');
renderPathsActions(county, config);
renderStepStatus();
if (jobsData && jobsData.running.length) disableButtons();
}
function renderPathsActions(county, config) {
@@ -738,11 +789,8 @@
/* ── Script runner ── */
function runScript(scriptName) {
if (!IS_AUTHENTICATED) return requireLogin();
const logsEl = document.getElementById('logs');
logsEl.textContent = '';
showStatus(`Running: ${scriptName} (${activeCounty})`, 'info');
hideStatus();
disableButtons();
document.getElementById('cancelButton').classList.add('active');
// On narrow screens the console is below the steps; bring it into view
if (window.matchMedia('(max-width: 768px)').matches) {
document.querySelector('.right').scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -757,68 +805,185 @@
fetch('/api/run-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: scriptName, county: activeCounty, forceDownload })
body: JSON.stringify({ script: scriptName, county: scriptName === 'ls' ? '' : activeCounty, forceDownload })
})
.then(r => r.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
return;
}
currentJobId = data.job_id;
if (logCheckInterval) clearInterval(logCheckInterval);
logCheckInterval = setInterval(checkJobStatus, 1000);
startStatsPolling();
followLatest = true;
lastSeenLatestId = data.job_id;
viewJob(data.job_id, true);
})
.catch(err => {
showStatus(`Error: ${err.message}`, 'error');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
});
}
function cancelJob() {
if (!currentJobId || !confirm('Cancel the running job?')) return;
if (!viewedJob || viewedJob.status !== 'running' || !confirm('Cancel the running job?')) return;
fetch('/api/cancel-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId })
body: JSON.stringify({ job_id: viewedJob.id })
})
.then(r => r.json())
.then(data => {
if (data.success) {
showStatus('Cancelled.', 'info');
clearInterval(logCheckInterval);
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
stopStatsPolling();
} else {
showStatus(`Cancel failed: ${data.error}`, 'error');
}
if (data.success) refreshJobs();
else showStatus(`Cancel failed: ${data.error}`, 'error');
});
}
function checkJobStatus() {
if (!currentJobId) return;
fetch(`/api/job-status/${currentJobId}`)
/* ── Shared job status (same on every device) ── */
function jobLabel(job) {
if (job.script === 'ls') return 'List files';
const step = document.querySelector(`.step[data-script="${job.script}"]`);
if (!step) return job.script;
const group = step.closest('.wf-group').querySelector('.wf-title').textContent.trim();
return `${group} · ${step.querySelector('.step-name').textContent.trim()}`;
}
function countyName(id) {
const c = COUNTY_CONFIG.find(c => c.id === id);
return c ? c.name : id;
}
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (d.toDateString() === new Date().toDateString()) return time;
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
}
function fmtDuration(fromIso, toIso) {
let s = Math.max(0, Math.round(((toIso ? new Date(toIso) : new Date()) - new Date(fromIso)) / 1000));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
s %= 60;
return h ? `${h}h ${m}m` : m ? `${m}m ${s}s` : `${s}s`;
}
const STATUS_TEXT = {
running: 'Running', success: 'Succeeded', failed: 'Failed',
cancelled: 'Cancelled', interrupted: 'Interrupted (server restarted)'
};
const STATUS_ICON = { running: '⟳', success: '✓', failed: '✗', cancelled: '⊘', interrupted: '⚠' };
function statusText(job) {
let t = STATUS_TEXT[job.status] || job.status;
if (job.status === 'failed' && job.exit_code !== null && job.exit_code !== undefined) t += ` (exit ${job.exit_code})`;
return t;
}
function renderBanner() {
const el = document.getElementById('jobBanner');
const job = viewedJob;
if (!job) { el.style.display = 'none'; return; }
const county = job.county ? ` (${countyName(job.county)})` : '';
const by = job.user ? ` by ${job.user}` : '';
const meta = job.status === 'running'
? `Started ${fmtTime(job.started)}${by} · ${fmtDuration(job.started)} elapsed`
: `Started ${fmtTime(job.started)}${by} · ended ${fmtTime(job.ended)} · took ${fmtDuration(job.started, job.ended)}`;
el.className = job.status;
el.innerHTML = '';
const title = document.createElement('span');
title.className = 'job-title';
title.textContent = `${STATUS_ICON[job.status] || ''} ${statusText(job)}: ${jobLabel(job)}${county}`;
el.appendChild(title);
if (!followLatest && jobsData && jobsData.latest && jobsData.latest.id !== job.id) {
const back = document.createElement('button');
back.className = 'job-back';
back.textContent = 'show latest';
back.onclick = () => { followLatest = true; viewJob(jobsData.latest.id); };
el.appendChild(back);
}
const m = document.createElement('span');
m.className = 'job-meta';
m.textContent = meta;
el.appendChild(m);
el.style.display = 'block';
document.getElementById('cancelButton').classList.toggle('active', job.status === 'running');
}
function renderStepStatus() {
document.querySelectorAll('.step[data-script]').forEach(step => {
const el = step.querySelector('.step-last');
const job = jobsData && jobsData.steps[`${step.dataset.script}|${activeCounty}`];
if (!job) { el.hidden = true; return; }
el.hidden = false;
el.className = `step-last ${job.status}`;
el.textContent = job.status === 'running'
? `${STATUS_ICON.running} Running · started ${fmtTime(job.started)}`
: `${STATUS_ICON[job.status] || ''} ${statusText(job)} · ${fmtTime(job.ended)}`;
el.title = `Started ${new Date(job.started).toLocaleString()}` +
(job.ended ? `, ended ${new Date(job.ended).toLocaleString()}` : '') +
(job.user ? ` by ${job.user}` : '') + ' — click to view output';
el.onclick = () => { followLatest = false; viewJob(job.id, true); };
});
}
// Load a job's output into the console and make it the banner subject.
function viewJob(jobId, scroll) {
return fetch(`/api/job-status/${encodeURIComponent(jobId)}`)
.then(r => r.json())
.then(data => {
if (data.error) return;
const wasRunning = viewedJob && viewedJob.id === data.job.id && viewedJob.status === 'running';
viewedJob = data.job;
const logsEl = document.getElementById('logs');
const atBottom = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 20;
logsEl.textContent = data.logs.join('');
logsEl.scrollTop = logsEl.scrollHeight;
if (!data.running) {
clearInterval(logCheckInterval);
showStatus('Done.', 'success');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
stopStatsPolling();
}
if (scroll || atBottom) logsEl.scrollTop = logsEl.scrollHeight;
if (data.job.status === 'running') startStatsPolling();
else if (wasRunning || statsInterval) stopStatsPolling();
renderBanner();
scheduleJobsRefresh();
})
.catch(err => console.error('Status check error:', err));
.catch(err => console.error('Job status error:', err));
}
function refreshJobs() {
return fetch('/api/jobs')
.then(r => r.json())
.then(data => {
if (data.error) return;
jobsData = data;
renderStepStatus();
if (data.running.length) disableButtons(); else if (!uploading) enableButtons();
const latest = data.latest;
// A newly started job (from any device) takes over the console
if (latest && latest.id !== lastSeenLatestId) {
lastSeenLatestId = latest.id;
followLatest = true;
}
const target = followLatest && latest ? latest.id : viewedJob && viewedJob.id;
const switching = target && (!viewedJob || viewedJob.id !== target);
if (switching || (viewedJob && viewedJob.status === 'running')) {
return viewJob(target, switching);
}
renderBanner();
})
.catch(err => console.error('Jobs refresh error:', err))
.finally(scheduleJobsRefresh);
}
// Poll quickly while something is running, slowly otherwise, so jobs
// started on another device show up here too.
function scheduleJobsRefresh() {
clearTimeout(jobsTimer);
const busy = (viewedJob && viewedJob.status === 'running') || (jobsData && jobsData.running.length);
jobsTimer = setTimeout(refreshJobs, busy ? 1000 : 5000);
}
if (IS_AUTHENTICATED) {
refreshJobs();
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshJobs(); });
} else {
document.getElementById('logs').innerHTML = '<span class="log-placeholder">Log in to see job output.</span>';
}
function uploadFile(inputId, dest) {
@@ -834,6 +999,7 @@
formData.append('dest', dest);
showStatus(`Uploading ${file.name}...`, 'info');
uploading = true;
disableButtons();
fetch('/api/upload-file', { method: 'POST', body: formData })
@@ -847,7 +1013,7 @@
}
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'))
.finally(() => enableButtons());
.finally(() => { uploading = false; refreshJobs(); });
}
function clearData() {
@@ -873,6 +1039,10 @@
el.style.display = 'block';
}
function hideStatus() {
document.getElementById('status').style.display = 'none';
}
function disableButtons() {
document.querySelectorAll('.btn-run').forEach(b => b.disabled = true);
}