858 lines
29 KiB
HTML
858 lines
29 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>
|
||
<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; }
|
||
|
||
.btn-topbar-icon {
|
||
padding: 5px 8px;
|
||
border: none;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: #ffffff18;
|
||
color: #ddd;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.btn-topbar-icon:hover { background: #ffffff30; }
|
||
|
||
.config-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
/* ── 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>
|
||
<div class="config-group">
|
||
<a href="/api/config/counties.yml" class="btn-topbar-icon" title="Download counties.yml">↓ counties.yml</a>
|
||
<label class="btn-topbar-icon" title="Upload counties.yml" style="cursor:pointer">↑<input type="file" style="display:none" accept=".yml,.yaml" onchange="uploadConfig('counties.yml', this)"></label>
|
||
</div>
|
||
<div class="config-group">
|
||
<a href="/api/config/exceptions.yml" class="btn-topbar-icon" title="Download exceptions.yml">↓ exceptions.yml</a>
|
||
<label class="btn-topbar-icon" title="Upload exceptions.yml" style="cursor:pointer">↑<input type="file" style="display:none" accept=".yml,.yaml" onchange="uploadConfig('exceptions.yml', this)"></label>
|
||
</div>
|
||
<div class="topbar-sep"></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>
|
||
|
||
<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 }};
|
||
let activeCounty = COUNTY_CONFIG.length ? COUNTY_CONFIG[0].id : '';
|
||
let currentJobId = null;
|
||
let logCheckInterval = null;
|
||
|
||
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 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>`;
|
||
});
|
||
}
|
||
|
||
// Init
|
||
if (activeCounty) selectCounty(activeCounty);
|
||
|
||
/* ── Script runner ── */
|
||
function runScript(scriptName) {
|
||
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) {
|
||
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 (!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 uploadConfig(filename, input) {
|
||
if (!input.files.length) return;
|
||
const formData = new FormData();
|
||
formData.append('file', input.files[0]);
|
||
showStatus(`Uploading ${filename}...`, 'info');
|
||
fetch(`/api/config/${filename}`, { method: 'POST', body: formData })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.error) {
|
||
showStatus(`Error: ${data.error}`, 'error');
|
||
} else {
|
||
showStatus(`${filename} uploaded successfully.`, 'success');
|
||
setTimeout(() => window.location.reload(), 800);
|
||
}
|
||
})
|
||
.catch(err => showStatus(`Error: ${err.message}`, 'error'))
|
||
.finally(() => { input.value = ''; });
|
||
}
|
||
|
||
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>
|