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

1433 lines
54 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>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#1a1a2e">
<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;
}
.files-strip { max-height: 38vh; overflow-y: auto; }
.files-strip[hidden] { display: none; }
.files-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.files-table th {
position: sticky;
top: 0;
background: #f8f9fa;
padding: 0;
text-align: left;
font-size: 10px;
font-weight: 700;
color: #888;
border-bottom: 1px solid #d8dbe0;
}
.files-table th button {
width: 100%;
padding: 3px 4px;
border: none;
background: none;
font: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
white-space: nowrap;
}
.files-table th button:hover { color: #333; }
.files-table th[aria-sort] button { color: #333; }
.files-table th[aria-sort="ascending"] button::after { content: ' ▲'; }
.files-table th[aria-sort="descending"] button::after { content: ' ▼'; }
.files-table th.col-date, .files-table td.col-date { text-align: right; width: 1%; white-space: nowrap; }
.files-table td { padding: 3px 4px; border-bottom: 1px solid #eceef1; }
.files-table td.col-name { word-break: break-all; }
.files-table td.col-date { color: #777; font-variant-numeric: tabular-nums; }
.files-table tbody.county-files tr.county-row td {
padding: 6px 4px 3px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--county-color, #555);
border-bottom: 2px solid var(--county-color, #ccc);
}
.files-table a { color: #1a5fb4; text-decoration: none; }
.files-table a:hover { text-decoration: underline; }
/* ── 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; }
.console-actions { display: flex; align-items: center; gap: 6px; }
.btn-notify {
padding: 3px 10px;
background: #2a2a2a;
color: #aaa;
border: 1px solid #3a3a3a;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.btn-notify:hover { color: #ddd; }
.btn-notify.on { background: #1e3a24; color: #6fcf7f; border-color: #2e5a36; }
.btn-notify[hidden] { display: none; }
/* One-time offer to turn on job-finished notifications */
#notifyPrompt {
display: none;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
padding: 7px 14px;
font-size: 12px;
background: #26233a;
color: #c9c2f0;
flex-shrink: 0;
}
#notifyPrompt.show { display: flex; }
#notifyPrompt span { flex: 1; min-width: 180px; }
#notifyPrompt button {
padding: 3px 10px;
border-radius: 4px;
border: 1px solid #4a4570;
background: none;
color: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
#notifyPrompt button.primary { background: #6c5ce7; border-color: #6c5ce7; color: white; }
#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: none; overflow: visible; }
.files-table { font-size: 13px; }
.files-table td { padding: 6px 4px; }
.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 (rows rendered by renderFiles()) -->
<div class="files-strip" id="filesStrip" hidden>
<div class="files-strip-title">Downloaded files</div>
<table class="files-table" id="filesTable">
<thead>
<tr>
<th class="col-name"><button type="button" data-sort="name">File</button></th>
<th class="col-date" aria-sort="descending"><button type="button" data-sort="ts">Modified</button></th>
</tr>
</thead>
</table>
</div>
</div><!-- /left -->
<!-- Right: console -->
<div class="right">
<div class="console-header">
<span>Output</span>
<div class="console-actions">
<button id="notifyButton" class="btn-notify" type="button" hidden onclick="toggleNotifications()">🔔 Notify me</button>
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
</div>
</div>
<div id="jobBanner" role="status" aria-live="polite"></div>
<div id="notifyPrompt">
<span>Get a notification when jobs finish, even with this page closed?</span>
<button type="button" class="primary" onclick="enableNotifications()">Turn on</button>
<button type="button" onclick="dismissNotifyPrompt()">Not now</button>
</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;
let finishedJobsKey = null; // finished job ids last seen, to spot completions
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);
/* ── Downloaded files table: grouped by county, sorted within each ── */
let dataFiles = {{ data_files_json | safe }};
let fileSortKey = 'ts', fileSortDir = -1;
function fmtFileTime(ts) {
const d = new Date(ts * 1000), pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function renderFiles() {
const table = document.getElementById('filesTable');
table.querySelectorAll('tbody').forEach(tb => tb.remove());
table.querySelectorAll('thead th').forEach(th => {
if (th.querySelector('button').dataset.sort === fileSortKey) {
th.setAttribute('aria-sort', fileSortDir < 0 ? 'descending' : 'ascending');
} else {
th.removeAttribute('aria-sort');
}
});
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
let any = false;
COUNTY_CONFIG.forEach(county => {
const files = (dataFiles[county.id] || []).slice();
if (!files.length) return;
any = true;
files.sort((a, b) => fileSortKey === 'ts'
? (a.ts - b.ts) * fileSortDir || byName(a, b)
: byName(a, b) * fileSortDir);
const tbody = document.createElement('tbody');
tbody.className = 'county-files';
tbody.style.setProperty('--county-color', county.color);
const head = tbody.insertRow();
head.className = 'county-row';
const hc = head.insertCell();
hc.colSpan = 2;
hc.textContent = county.name;
files.forEach(f => {
const row = tbody.insertRow();
row.className = 'file-row';
const link = document.createElement('a');
link.href = `/data/${encodeURIComponent(county.id)}/${encodeURIComponent(f.name)}`;
link.textContent = f.name;
const nameCell = row.insertCell();
nameCell.className = 'col-name';
nameCell.appendChild(link);
const dateCell = row.insertCell();
dateCell.className = 'col-date';
dateCell.textContent = fmtFileTime(f.ts);
dateCell.title = new Date(f.ts * 1000).toLocaleString();
});
table.appendChild(tbody);
});
document.getElementById('filesStrip').hidden = !any;
}
function refreshFiles() {
return fetch('/api/data-files')
.then(r => r.json())
.then(data => { dataFiles = data; renderFiles(); })
.catch(err => console.error('File list refresh error:', err));
}
document.querySelectorAll('#filesTable thead button').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.sort;
// Same column toggles direction; a new column starts with its natural order
if (key === fileSortKey) fileSortDir = -fileSortDir;
else { fileSortKey = key; fileSortDir = key === 'ts' ? -1 : 1; }
renderFiles();
});
});
renderFiles();
/* ── 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,
label: jobLabel({ script: scriptName }),
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);
maybeOfferNotifications();
})
.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;
// Any job finishing (here or on another device) may have written files
const finished = Object.values(data.steps).filter(j => j.status !== 'running')
.map(j => j.id).sort().join();
if (finishedJobsKey !== null && finished !== finishedJobsKey) refreshFiles();
finishedJobsKey = finished;
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);
}
/* ── Web push: browser notifications when jobs finish ── */
const PUSH_DISMISS_KEY = 'notifyPromptDismissed';
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const pushSupported = window.isSecureContext && 'serviceWorker' in navigator &&
'PushManager' in window && 'Notification' in window;
let pushSubscription = null;
function pushUnsupportedReason() {
if (!window.isSecureContext) return 'Notifications need this site to be opened over HTTPS (or on localhost).';
if (IS_IOS) return 'On iPhone/iPad, add this site to your Home Screen (Share → Add to Home Screen) and open it from there to get notifications.';
return 'This browser does not support web push notifications.';
}
function renderNotifyButton() {
const btn = document.getElementById('notifyButton');
btn.hidden = false;
const on = !!pushSubscription;
btn.classList.toggle('on', on);
btn.textContent = on ? '🔔 Notifications on' : '🔔 Notify me';
btn.title = !pushSupported ? pushUnsupportedReason()
: on ? 'You get a notification when a job finishes. Click to turn off.'
: 'Get a browser notification when a job finishes';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function urlBase64ToUint8Array(b64) {
const raw = atob((b64 + '='.repeat((4 - b64.length % 4) % 4)).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
function sendSubscription(sub) {
return fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: sub.toJSON() })
}).then(r => r.json()).then(data => { if (data.error) throw new Error(data.error); });
}
async function enableNotifications() {
hideNotifyPrompt();
if (!pushSupported) { showStatus(pushUnsupportedReason(), 'error'); return; }
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
showStatus('Notifications are blocked for this site. Allow them in your browser\'s site settings to turn them on.', 'error');
return;
}
const keyResp = await fetch('/api/push/key').then(r => r.json());
if (keyResp.error) throw new Error(keyResp.error);
const reg = await navigator.serviceWorker.ready;
const key = urlBase64ToUint8Array(keyResp.publicKey);
let sub = await reg.pushManager.getSubscription();
// A subscription made with a different server key can't receive our pushes
if (sub && sub.options.applicationServerKey &&
new Uint8Array(sub.options.applicationServerKey).join() !== key.join()) {
await sub.unsubscribe();
sub = null;
}
sub = sub || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
await sendSubscription(sub);
pushSubscription = sub;
showStatus('Notifications on: this browser will be notified when jobs finish.', 'success');
} catch (err) {
showStatus(`Could not turn on notifications: ${err.message}`, 'error');
}
renderNotifyButton();
}
async function disableNotifications() {
const sub = pushSubscription;
pushSubscription = null;
renderNotifyButton();
if (!sub) return;
try {
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint })
});
await sub.unsubscribe();
showStatus('Notifications off for this browser.', 'info');
} catch (err) {
showStatus(`Could not turn off notifications: ${err.message}`, 'error');
}
}
function toggleNotifications() {
if (pushSubscription) disableNotifications(); else enableNotifications();
}
// After starting a job is when a "tell me when it's done" offer makes sense
function maybeOfferNotifications() {
if (!pushSupported || pushSubscription || Notification.permission === 'denied') return;
try { if (localStorage.getItem(PUSH_DISMISS_KEY)) return; } catch (e) { /* storage blocked */ }
document.getElementById('notifyPrompt').classList.add('show');
}
function hideNotifyPrompt() {
document.getElementById('notifyPrompt').classList.remove('show');
}
function dismissNotifyPrompt() {
hideNotifyPrompt();
try { localStorage.setItem(PUSH_DISMISS_KEY, '1'); } catch (e) { /* storage blocked */ }
}
async function initNotifications() {
if (!pushSupported) { renderNotifyButton(); return; }
try {
const reg = await navigator.serviceWorker.register('/sw.js');
const sub = await reg.pushManager.getSubscription();
if (sub && Notification.permission === 'granted') {
// Re-register so the server has it even if its list was reset
await sendSubscription(sub);
pushSubscription = sub;
}
} catch (err) {
console.error('Notification setup failed:', err);
}
renderNotifyButton();
}
if (IS_AUTHENTICATED) {
initNotifications();
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');
refreshFiles();
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() {
if (statsInterval) return; // already polling; keep the history
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>