1432 lines
54 KiB
HTML
1432 lines
54 KiB
HTML
<!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() {
|
||
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>
|