diff --git a/web/server.py b/web/server.py
index f7b4e30..89a02c4 100644
--- a/web/server.py
+++ b/web/server.py
@@ -9,6 +9,11 @@ import threading
import json
from datetime import datetime
import yaml
+try:
+ import psutil
+ _has_psutil = True
+except ImportError:
+ _has_psutil = False
app = Flask(__name__, static_folder='static', template_folder='templates')
@@ -251,6 +256,19 @@ def upload_file():
f.save(dest)
return jsonify({'success': True, 'path': dest})
+@app.route('/api/system-stats')
+def system_stats():
+ """Return current CPU, memory, and disk usage percentages."""
+ if not _has_psutil:
+ return jsonify({'error': 'psutil not installed'}), 503
+ cpu = psutil.cpu_percent(interval=None)
+ mem = psutil.virtual_memory().percent
+ try:
+ disk = psutil.disk_usage('/data').percent
+ except Exception:
+ disk = psutil.disk_usage('/').percent
+ return jsonify({'cpu': cpu, 'mem': mem, 'disk': disk})
+
@app.route('/api/list-files')
def list_files():
"""List available GeoJSON files"""
diff --git a/web/templates/index.html b/web/templates/index.html
index 02c73d8..9d382bb 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -109,6 +109,7 @@
/* Scrollable workflow area */
.workflows {
flex: 1;
+ min-height: 0;
overflow-y: auto;
padding: 14px 16px;
display: flex;
@@ -300,6 +301,37 @@
#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;
@@ -479,6 +511,23 @@
+
+
+ CPU
+
+ —
+
+
+ MEM
+
+ —
+
+
+ DISK
+
+ —
+
+
Run a step to see output here.
@@ -570,6 +619,7 @@
currentJobId = data.job_id;
if (logCheckInterval) clearInterval(logCheckInterval);
logCheckInterval = setInterval(checkJobStatus, 1000);
+ startStatsPolling();
})
.catch(err => {
showStatus(`Error: ${err.message}`, 'error');
@@ -593,6 +643,7 @@
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
+ stopStatsPolling();
} else {
showStatus(`Cancel failed: ${data.error}`, 'error');
}
@@ -613,6 +664,7 @@
enableButtons();
document.getElementById('cancelButton').classList.remove('active');
currentJobId = null;
+ stopStatsPolling();
}
})
.catch(err => console.error('Status check error:', err));
@@ -660,6 +712,84 @@
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';
+ }