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

829 lines
28 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-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;
}
#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;
}
</style>
</head>
<body>
<div class="page">
<!-- Top bar -->
<div class="topbar">
<h1>OSM Import Tools</h1>
<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 style="flex:1"></div>
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
<div class="topbar-sep"></div>
{% if current_user %}
<span style="font-size:11px;color:#888;">{{ 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>
<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">
<div class="step-num">1</div>
<div class="step-label">Download county data</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-county-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-label">Download OSM roads</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-osm-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('convert-roads')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</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">
<div class="step-num">1</div>
<div class="step-label">Upload county paths</div>
<div class="step-actions" id="paths-step-1-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-label">Download OSM paths</div>
<div class="step-actions" id="paths-step-2-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-actions" id="paths-step-3-actions">
<!-- filled by selectCounty() -->
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</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">
<div class="step-num">1</div>
<div class="step-label">Download county data</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-county-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-label">Download OSM addresses</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('download-osm-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-label">Convert to GeoJSON</div>
<div class="step-actions">
<button class="btn-run" onclick="runScript('convert-addresses')">Run</button>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-label">Diff vs OSM</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>
</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="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 currentJobId = null;
let logCheckInterval = null;
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);
}
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 24
[['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>`;
});
}
// Init
if (activeCounty) selectCounty(activeCounty);
/* ── Script runner ── */
function runScript(scriptName) {
if (!IS_AUTHENTICATED) return requireLogin();
const logsEl = document.getElementById('logs');
logsEl.textContent = '';
showStatus(`Running: ${scriptName} (${activeCounty})`, 'info');
disableButtons();
document.getElementById('cancelButton').classList.add('active');
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: activeCounty, forceDownload })
})
.then(r => r.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
return;
}
currentJobId = data.job_id;
if (logCheckInterval) clearInterval(logCheckInterval);
logCheckInterval = setInterval(checkJobStatus, 1000);
startStatsPolling();
})
.catch(err => {
showStatus(`Error: ${err.message}`, 'error');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
});
}
function cancelJob() {
if (!currentJobId || !confirm('Cancel the running job?')) return;
fetch('/api/cancel-job', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: currentJobId })
})
.then(r => r.json())
.then(data => {
if (data.success) {
showStatus('Cancelled.', 'info');
clearInterval(logCheckInterval);
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
stopStatsPolling();
} else {
showStatus(`Cancel failed: ${data.error}`, 'error');
}
});
}
function checkJobStatus() {
if (!currentJobId) return;
fetch(`/api/job-status/${currentJobId}`)
.then(r => r.json())
.then(data => {
const logsEl = document.getElementById('logs');
logsEl.textContent = data.logs.join('');
logsEl.scrollTop = logsEl.scrollHeight;
if (!data.running) {
clearInterval(logCheckInterval);
showStatus('Done.', 'success');
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
stopStatsPolling();
}
})
.catch(err => console.error('Status check error:', err));
}
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');
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(() => enableButtons());
}
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 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>