Sortable per-county downloaded files table that refreshes when jobs finish, and web push notifications for job completion
This commit is contained in:
+328
-30
@@ -4,6 +4,8 @@
|
||||
<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; }
|
||||
|
||||
@@ -255,22 +257,60 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.file-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
.files-strip { max-height: 38vh; overflow-y: auto; }
|
||||
.files-strip[hidden] { display: none; }
|
||||
|
||||
.files-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
.files-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #d8dbe0;
|
||||
}
|
||||
|
||||
.file-link { background: var(--link-color, #888); }
|
||||
.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 {
|
||||
@@ -312,6 +352,48 @@
|
||||
|
||||
.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;
|
||||
@@ -492,7 +574,11 @@
|
||||
|
||||
.upload-input { width: 100%; max-width: 200px; }
|
||||
|
||||
.files-strip { max-height: 160px; overflow-y: auto; }
|
||||
.files-strip { max-height: none; overflow: visible; }
|
||||
|
||||
.files-table { font-size: 13px; }
|
||||
|
||||
.files-table td { padding: 6px 4px; }
|
||||
|
||||
.right {
|
||||
min-height: 60vh;
|
||||
@@ -659,24 +745,18 @@
|
||||
|
||||
</div><!-- /workflows -->
|
||||
|
||||
<!-- Data files strip -->
|
||||
{% if data_files %}
|
||||
<div class="files-strip">
|
||||
<!-- Data files strip (rows rendered by renderFiles()) -->
|
||||
<div class="files-strip" id="filesStrip" hidden>
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
</div><!-- /left -->
|
||||
|
||||
@@ -684,9 +764,17 @@
|
||||
<div class="right">
|
||||
<div class="console-header">
|
||||
<span>Output</span>
|
||||
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
|
||||
<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">
|
||||
@@ -721,6 +809,7 @@
|
||||
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');
|
||||
@@ -786,6 +875,79 @@
|
||||
// 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();
|
||||
@@ -805,7 +967,12 @@
|
||||
fetch('/api/run-script', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ script: scriptName, county: scriptName === 'ls' ? '' : activeCounty, forceDownload })
|
||||
body: JSON.stringify({
|
||||
script: scriptName,
|
||||
county: scriptName === 'ls' ? '' : activeCounty,
|
||||
label: jobLabel({ script: scriptName }),
|
||||
forceDownload
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -817,6 +984,7 @@
|
||||
followLatest = true;
|
||||
lastSeenLatestId = data.job_id;
|
||||
viewJob(data.job_id, true);
|
||||
maybeOfferNotifications();
|
||||
})
|
||||
.catch(err => {
|
||||
showStatus(`Error: ${err.message}`, 'error');
|
||||
@@ -951,6 +1119,11 @@
|
||||
.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();
|
||||
@@ -979,7 +1152,131 @@
|
||||
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 {
|
||||
@@ -1009,6 +1306,7 @@
|
||||
showStatus(`Error: ${data.error}`, 'error');
|
||||
} else {
|
||||
showStatus(`Uploaded to ${data.path}`, 'success');
|
||||
refreshFiles();
|
||||
input.value = '';
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user