Files
osm-import-tools/web/templates/index.html
T

1134 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OSM Import Tools</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #eef0f3;
color: #222;
}
/* ── Page shell ── */
.page {
height: 100%;
display: flex;
flex-direction: column;
}
/* ── Top bar ── */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #1a1a2e;
flex-shrink: 0;
}
.topbar h1 {
font-size: 16px;
font-weight: 700;
color: #fff;
margin-right: 4px;
}
.topbar-sep {
width: 1px;
height: 20px;
background: #ffffff30;
}
.btn-topbar {
padding: 5px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #ffffff18;
color: #ddd;
text-decoration: none;
}
.btn-topbar:hover { background: #ffffff30; }
.btn-topbar.purple {
background: #6f42c1;
color: white;
}
.btn-topbar.purple:hover { background: #5a32a3; }
/* ── Main two-panel layout ── */
.main {
flex: 1;
display: flex;
overflow: hidden;
}
/* ── Left panel (workflows) ── */
.left {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #d8dbe0;
overflow: hidden;
}
/* County tabs */
.county-tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid #d8dbe0;
}
.county-tab {
flex: 1;
padding: 10px;
border: none;
background: #f4f5f7;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #888;
border-bottom: 3px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.county-tab:hover { color: #444; }
.county-tab.active { background: white; border-bottom-color: var(--county-color, #28a745); color: var(--county-color, #28a745); }
/* Scrollable workflow area */
.workflows {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Workflow group (Roads / Paths / Addresses) */
.wf-group {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
}
.wf-title {
padding: 8px 14px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.7px;
color: #555;
background: #f8f9fa;
border-bottom: 1px solid #eee;
}
/* Steps */
.step {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-bottom: 1px solid #f3f3f3;
}
.step:last-child { border-bottom: none; }
.step-num {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e8eaf6;
color: #3949ab;
font-size: 10px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-label {
flex: 1;
font-size: 12px;
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;
gap: 6px;
flex-shrink: 0;
}
/* Run buttons */
.btn-run {
padding: 5px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: white;
transition: filter 0.15s;
}
.btn-run:hover { filter: brightness(0.88); }
.btn-run:disabled { filter: brightness(0.5); cursor: not-allowed; }
.left .btn-run { background: var(--active-color, #28a745); }
/* Force redownload */
.force-label {
font-size: 10px;
color: #aaa;
display: flex;
align-items: center;
gap: 3px;
cursor: pointer;
white-space: nowrap;
}
/* Upload (paths) */
.upload-input {
font-size: 11px;
width: 120px;
color: #555;
}
/* N/A badge */
.na {
font-size: 10px;
color: #ccc;
font-style: italic;
}
/* Data files strip */
.files-strip {
flex-shrink: 0;
border-top: 1px solid #d8dbe0;
padding: 8px 14px;
background: #f8f9fa;
}
.files-strip-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #aaa;
margin-bottom: 5px;
}
.file-links {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.file-link {
font-size: 10px;
padding: 2px 7px;
border-radius: 3px;
color: white;
text-decoration: none;
font-weight: 500;
}
.file-link { background: var(--link-color, #888); }
/* ── Right panel (console) ── */
.right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
}
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: #111;
flex-shrink: 0;
}
.console-header span {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #666;
}
.btn-cancel {
padding: 3px 10px;
background: #c0392b;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
display: none;
}
.btn-cancel.active { display: inline-block; }
#status {
padding: 7px 14px;
font-size: 12px;
flex-shrink: 0;
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; }
#sys-stats {
flex-shrink: 0;
display: none;
padding: 6px 14px 8px;
background: #141414;
border-bottom: 1px solid #2a2a2a;
}
.stat-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.stat-label {
font-size: 10px;
font-weight: 700;
color: #666;
width: 30px;
letter-spacing: 0.3px;
}
.stat-val {
font-size: 10px;
color: #888;
width: 34px;
text-align: right;
font-family: 'Courier New', monospace;
}
#logs {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #d4d4d4;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-placeholder {
color: #444;
font-style: italic;
}
/* ── Collapsible top menu (mobile) ── */
.topbar-menu {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topbar-spacer { flex: 1; }
.topbar-user { font-size: 11px; color: #888; }
.menu-toggle {
display: none;
margin-left: auto;
width: 40px;
height: 36px;
padding: 8px;
border: none;
border-radius: 5px;
background: #ffffff18;
cursor: pointer;
flex-direction: column;
justify-content: space-between;
}
.menu-toggle span {
display: block;
height: 2px;
border-radius: 1px;
background: #ddd;
}
@media (max-width: 768px) {
html, body { height: auto; }
.page { height: auto; min-height: 100%; }
.topbar {
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
}
.menu-toggle { display: flex; }
.topbar-menu {
display: none;
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
gap: 6px;
max-width: 560px;
margin: 0 auto;
padding: 4px 0 6px;
}
.topbar-menu.open { display: flex; }
.topbar-sep, .topbar-spacer { display: none; }
.topbar-menu .btn-topbar,
.topbar-menu form,
.topbar-menu form .btn-topbar {
display: block;
width: 100%;
padding: 10px 12px;
font-size: 14px;
text-align: left;
}
.topbar-user { padding: 4px 2px 0; font-size: 12px; }
.main {
flex-direction: column;
overflow: visible;
}
.left {
width: 100%;
max-width: 560px;
margin: 0 auto;
border-right: none;
overflow: visible;
}
.workflows { overflow: visible; padding: 12px; }
.county-tab { padding: 12px; font-size: 14px; }
.step { flex-wrap: wrap; padding: 10px 12px; }
.step-label { font-size: 13px; min-width: 120px; }
.step-actions { margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
.btn-run { padding: 8px 16px; font-size: 13px; }
.upload-input { width: 100%; max-width: 200px; }
.files-strip { max-height: 160px; overflow-y: auto; }
.right {
min-height: 60vh;
border-top: 1px solid #d8dbe0;
}
}
</style>
</head>
<body>
<div class="page">
<!-- Top bar -->
<div class="topbar">
<h1>OSM Import Tools</h1>
<button class="menu-toggle" id="menuToggle" aria-label="Toggle menu" aria-expanded="false" aria-controls="topbarMenu">
<span></span><span></span><span></span>
</button>
<div class="topbar-menu" id="topbarMenu">
<div class="topbar-sep"></div>
<button class="btn-topbar" onclick="runScript('ls', '')">List Files</button>
<button class="btn-topbar" onclick="clearData()" style="color:#f88;">Clear Data</button>
<div class="topbar-spacer"></div>
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
<a href="/reports" class="btn-topbar">Reports</a>
<a href="/map-checker" class="btn-topbar">Boundary Tracer</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
<div class="topbar-sep"></div>
{% if current_user %}
<span class="topbar-user">{{ current_user }}</span>
<form method="POST" action="/logout" style="display:inline;margin:0">
<button type="submit" class="btn-topbar">Logout</button>
</form>
{% else %}
<a href="/login" class="btn-topbar" style="color:#f0a830;">Login</a>
{% endif %}
</div><!-- /topbar-menu -->
</div>
<div class="main">
<!-- Left: workflows -->
<div class="left" id="leftPanel">
<div class="county-tabs">
{% for county in counties %}
<button class="county-tab" data-county="{{ county.id }}"
style="--county-color: {{ county.color }}"
onclick="selectCounty('{{ county.id }}')">{{ county.name }}</button>
{% endfor %}
</div>
<div class="workflows">
<!-- Roads -->
<div class="wf-group">
<div class="wf-title">Roads</div>
<div class="step" data-script="download-county-roads">
<div class="step-num">1</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" data-script="download-osm-roads">
<div class="step-num">2</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" data-script="convert-roads">
<div class="step-num">3</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" data-script="diff-roads">
<div class="step-num">4</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>
</div>
</div>
<!-- Paths (Sumter only) -->
<div class="wf-group" id="paths-group">
<div class="wf-title">Paths</div>
<div class="step" id="paths-step-1" data-script="download-county-paths">
<div class="step-num">1</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" data-script="download-osm-paths">
<div class="step-num">2</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" data-script="convert-paths">
<div class="step-num">3</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" data-script="diff-paths">
<div class="step-num">4</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>
</div>
</div>
<!-- Addresses -->
<div class="wf-group">
<div class="wf-title">Addresses</div>
<div class="step" data-script="download-county-addresses">
<div class="step-num">1</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" data-script="download-osm-addresses">
<div class="step-num">2</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" data-script="convert-addresses">
<div class="step-num">3</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" data-script="diff-addresses">
<div class="step-num">4</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">
<input type="checkbox" id="force-redownload"> Force redownload
</label>
</div>
</div>
<div class="step" data-script="generate-city-report">
<div class="step-num">5</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>
</div>
</div>
</div><!-- /workflows -->
<!-- Data files strip -->
{% if data_files %}
<div class="files-strip">
<div class="files-strip-title">Downloaded files</div>
<div class="file-links">
{% for county in counties %}
{% if county.id in data_files %}
{% for file in data_files[county.id] %}
<a class="file-link"
style="--link-color: {{ county.color }}"
href="/data/{{ county.id }}/{{ file.name }}"
title="{{ county.id }}/{{ file.name }} — {{ file.mtime }}">{{ file.name }}</a>
{% endfor %}
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div><!-- /left -->
<!-- Right: console -->
<div class="right">
<div class="console-header">
<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">
<span class="stat-label">CPU</span>
<canvas id="cpu-canvas" width="180" height="22"></canvas>
<span class="stat-val" id="cpu-val">—</span>
</div>
<div class="stat-row">
<span class="stat-label">MEM</span>
<canvas id="mem-canvas" width="180" height="22"></canvas>
<span class="stat-val" id="mem-val">—</span>
</div>
<div class="stat-row">
<span class="stat-label">DISK</span>
<canvas id="disk-canvas" width="180" height="22"></canvas>
<span class="stat-val" id="disk-val">—</span>
</div>
</div>
<div id="logs"><span class="log-placeholder">Run a step to see output here.</span></div>
</div>
</div><!-- /main -->
</div><!-- /page -->
<script>
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 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');
setTimeout(() => { window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); }, 1200);
return false;
}
function selectCounty(county) {
activeCounty = county;
const config = COUNTY_CONFIG.find(c => c.id === county);
// Update tab styles
document.querySelectorAll('.county-tab').forEach(t => t.classList.remove('active'));
const tab = document.querySelector(`.county-tab[data-county="${county}"]`);
if (tab) tab.classList.add('active');
// Update button color via CSS custom property
const panel = document.getElementById('leftPanel');
panel.style.setProperty('--active-color', config ? config.color : '#28a745');
renderPathsActions(county, config);
renderStepStatus();
if (jobsData && jobsData.running.length) disableButtons();
}
function renderPathsActions(county, config) {
const pathsUrl = config ? (config.paths_url || '') : '';
const na = '<span class="na">Not available</span>';
if (!pathsUrl) {
['1','2','3','4'].forEach(n => {
document.getElementById(`paths-step-${n}-actions`).innerHTML = na;
});
return;
}
// Step 1: upload or download
const inputId = `upload-${county}-paths`;
const dest = `/data/${county}/paths.shp.zip`;
if (pathsUrl === 'upload') {
document.getElementById('paths-step-1-actions').innerHTML =
`<input type="file" id="${inputId}" class="upload-input" accept=".zip">` +
`<button class="btn-run" onclick="uploadFile('${inputId}', '${dest}')">Upload</button>`;
} else {
document.getElementById('paths-step-1-actions').innerHTML =
`<button class="btn-run" onclick="runScript('download-county-paths')">Run</button>`;
}
// Steps 2–4
[['2','download-osm-paths'],['3','convert-paths'],['4','diff-paths']].forEach(([n, script]) => {
document.getElementById(`paths-step-${n}-actions`).innerHTML =
`<button class="btn-run" onclick="runScript('${script}')">Run</button>`;
});
}
// Mobile top menu toggle
document.getElementById('menuToggle').addEventListener('click', function() {
const menu = document.getElementById('topbarMenu');
const open = menu.classList.toggle('open');
this.setAttribute('aria-expanded', open ? 'true' : 'false');
});
// Init
if (activeCounty) selectCounty(activeCounty);
/* ── Script runner ── */
function runScript(scriptName) {
if (!IS_AUTHENTICATED) return requireLogin();
hideStatus();
disableButtons();
// 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' });
}
let forceDownload = false;
if (scriptName === 'diff-addresses') {
const cb = document.getElementById('force-redownload');
if (cb) forceDownload = cb.checked;
}
fetch('/api/run-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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();
return;
}
followLatest = true;
lastSeenLatestId = data.job_id;
viewJob(data.job_id, true);
})
.catch(err => {
showStatus(`Error: ${err.message}`, 'error');
enableButtons();
});
}
function cancelJob() {
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: viewedJob.id })
})
.then(r => r.json())
.then(data => {
if (data.success) refreshJobs();
else showStatus(`Cancel failed: ${data.error}`, 'error');
});
}
/* ── 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('');
if (scroll || atBottom) logsEl.scrollTop = logsEl.scrollHeight;
if (data.job.status === 'running') startStatsPolling();
else if (wasRunning || statsInterval) stopStatsPolling();
renderBanner();
scheduleJobsRefresh();
})
.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) {
if (!IS_AUTHENTICATED) return requireLogin();
const input = document.getElementById(inputId);
if (!input || !input.files.length) {
showStatus('Please select a file first.', 'error');
return;
}
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
formData.append('dest', dest);
showStatus(`Uploading ${file.name}...`, 'info');
uploading = true;
disableButtons();
fetch('/api/upload-file', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
} else {
showStatus(`Uploaded to ${data.path}`, 'success');
input.value = '';
}
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'))
.finally(() => { uploading = false; refreshJobs(); });
}
function clearData() {
if (!IS_AUTHENTICATED) return requireLogin();
if (!confirm('Delete all downloaded and processed files from all county folders?')) return;
fetch('/api/clear-data', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
} else {
showStatus(`Cleared ${data.removed.length} file(s).`, 'success');
setTimeout(() => window.location.reload(), 800);
}
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'));
}
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
}
function hideStatus() {
document.getElementById('status').style.display = 'none';
}
function disableButtons() {
document.querySelectorAll('.btn-run').forEach(b => b.disabled = true);
}
function enableButtons() {
document.querySelectorAll('.btn-run').forEach(b => b.disabled = false);
}
/* ── System stats sparklines ── */
const MAX_SAMPLES = 60;
const statHistory = { cpu: [], mem: [], disk: [] };
let statsInterval = null;
const sparkColors = { cpu: '#e07070', mem: '#70b8e0', disk: '#f0a830' };
function drawSparkline(canvasId, data, color) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, W, H);
if (data.length < 2) return;
const step = W / (MAX_SAMPLES - 1);
const startX = W - (data.length - 1) * step;
// Fill
ctx.beginPath();
ctx.moveTo(startX, H);
data.forEach((v, i) => {
ctx.lineTo(startX + i * step, H - (v / 100) * (H - 2) - 1);
});
ctx.lineTo(startX + (data.length - 1) * step, H);
ctx.closePath();
ctx.fillStyle = color + '33';
ctx.fill();
// Line
ctx.beginPath();
data.forEach((v, i) => {
const x = startX + i * step;
const y = H - (v / 100) * (H - 2) - 1;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
function pollSystemStats() {
fetch('/api/system-stats')
.then(r => r.json())
.then(data => {
if (data.error) return;
['cpu', 'mem', 'disk'].forEach(k => {
statHistory[k].push(data[k]);
if (statHistory[k].length > MAX_SAMPLES) statHistory[k].shift();
});
document.getElementById('cpu-val').textContent = data.cpu.toFixed(0) + '%';
document.getElementById('mem-val').textContent = data.mem.toFixed(0) + '%';
document.getElementById('disk-val').textContent = data.disk.toFixed(0) + '%';
drawSparkline('cpu-canvas', statHistory.cpu, sparkColors.cpu);
drawSparkline('mem-canvas', statHistory.mem, sparkColors.mem);
drawSparkline('disk-canvas', statHistory.disk, sparkColors.disk);
})
.catch(() => {});
}
function startStatsPolling() {
document.getElementById('sys-stats').style.display = 'block';
statHistory.cpu = []; statHistory.mem = []; statHistory.disk = [];
pollSystemStats();
statsInterval = setInterval(pollSystemStats, 2000);
}
function stopStatsPolling() {
clearInterval(statsInterval);
statsInterval = null;
document.getElementById('sys-stats').style.display = 'none';
}
</script>
</body>
</html>