Sortable per-county downloaded files table that refreshes when jobs finish, and web push notifications for job completion
This commit is contained in:
@@ -122,6 +122,12 @@ docker push your-registry.example.com/you/osm-import-tools
|
|||||||
- Login required for all write operations
|
- Login required for all write operations
|
||||||
- Admin user configured via `ADMIN_PASSWORD` environment variable
|
- Admin user configured via `ADMIN_PASSWORD` environment variable
|
||||||
|
|
||||||
|
### Job history and notifications
|
||||||
|
- Every job run is recorded in `/data/jobs/`, so its output, start/end time and result are visible to any logged-in user on any device
|
||||||
|
- **Notify me** (Output panel) subscribes the browser to a web push notification whenever a job finishes
|
||||||
|
- Web push requires HTTPS (or `http://localhost`); on iPhone/iPad, add the site to the Home Screen first
|
||||||
|
- Set `VAPID_SUBJECT` to a real `mailto:` contact; VAPID keys are generated once into `/data/.vapid_private.pem`
|
||||||
|
|
||||||
## Volume Mounts
|
## Volume Mounts
|
||||||
|
|
||||||
The Docker container mounts a single data directory:
|
The Docker container mounts a single data directory:
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ shapely>=2.0.0
|
|||||||
pyproj>=3.6.0
|
pyproj>=3.6.0
|
||||||
rtree>=1.1.0
|
rtree>=1.1.0
|
||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
|
pywebpush>=2.0.0
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ ADMIN_PASSWORD=changeme
|
|||||||
# Secret key for Flask session signing.
|
# Secret key for Flask session signing.
|
||||||
# Generate a strong random value, e.g.: python -c "import secrets; print(secrets.token_hex(32))"
|
# Generate a strong random value, e.g.: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
FLASK_SECRET_KEY=change-this-to-a-long-random-string
|
FLASK_SECRET_KEY=change-this-to-a-long-random-string
|
||||||
|
|
||||||
|
# Contact for web push (job-finished notifications). Push services may use it
|
||||||
|
# to reach you about problems; Apple rejects placeholder/localhost addresses.
|
||||||
|
# VAPID keys are generated automatically into /data/.vapid_private.pem.
|
||||||
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|||||||
+158
-9
@@ -3,7 +3,7 @@
|
|||||||
Flask web server for The Villages Import Tools
|
Flask web server for The Villages Import Tools
|
||||||
"""
|
"""
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import Flask, render_template, jsonify, request, send_from_directory, session, redirect, url_for
|
from flask import Flask, render_template, jsonify, request, send_from_directory, session, redirect, url_for, make_response
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
@@ -13,6 +13,8 @@ import shlex
|
|||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import yaml
|
import yaml
|
||||||
try:
|
try:
|
||||||
@@ -20,6 +22,13 @@ try:
|
|||||||
_has_psutil = True
|
_has_psutil = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_has_psutil = False
|
_has_psutil = False
|
||||||
|
try:
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
from py_vapid import Vapid
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
_has_webpush = True
|
||||||
|
except ImportError:
|
||||||
|
_has_webpush = False
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='static', template_folder='templates')
|
app = Flask(__name__, static_folder='static', template_folder='templates')
|
||||||
|
|
||||||
@@ -201,6 +210,84 @@ def _job_logs(job_id):
|
|||||||
_load_jobs()
|
_load_jobs()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Web push: notify subscribed browsers when a job finishes ─────────────────
|
||||||
|
# VAPID keys are generated once into /data (like the Flask secret key), or set
|
||||||
|
# VAPID_PRIVATE_KEY_FILE to use your own PEM. Browsers only allow push on HTTPS
|
||||||
|
# (or http://localhost).
|
||||||
|
|
||||||
|
VAPID_KEY_FILE = os.environ.get('VAPID_PRIVATE_KEY_FILE', '/data/.vapid_private.pem')
|
||||||
|
VAPID_SUBJECT = os.environ.get('VAPID_SUBJECT', 'mailto:admin@example.org')
|
||||||
|
PUSH_SUBSCRIPTIONS_FILE = '/data/push-subscriptions.json'
|
||||||
|
push_lock = threading.Lock()
|
||||||
|
_vapid = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vapid():
|
||||||
|
global _vapid
|
||||||
|
if _vapid is None and _has_webpush:
|
||||||
|
if os.path.exists(VAPID_KEY_FILE):
|
||||||
|
_vapid = Vapid.from_file(VAPID_KEY_FILE)
|
||||||
|
else:
|
||||||
|
_vapid = Vapid()
|
||||||
|
_vapid.generate_keys()
|
||||||
|
_vapid.save_key(VAPID_KEY_FILE)
|
||||||
|
os.chmod(VAPID_KEY_FILE, 0o600)
|
||||||
|
return _vapid
|
||||||
|
|
||||||
|
|
||||||
|
def _vapid_public_key():
|
||||||
|
raw = _get_vapid().public_key.public_bytes(
|
||||||
|
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip('=')
|
||||||
|
|
||||||
|
|
||||||
|
def _load_push_subscriptions():
|
||||||
|
try:
|
||||||
|
with open(PUSH_SUBSCRIPTIONS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_push_subscriptions(subs):
|
||||||
|
tmp = PUSH_SUBSCRIPTIONS_FILE + '.tmp'
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
json.dump(subs, f)
|
||||||
|
os.replace(tmp, PUSH_SUBSCRIPTIONS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_job_finished(job):
|
||||||
|
"""Push a notification about a finished job to every subscribed browser.
|
||||||
|
Subscriptions the push service reports as gone are dropped."""
|
||||||
|
if not _has_webpush:
|
||||||
|
return
|
||||||
|
with push_lock:
|
||||||
|
subs = _load_push_subscriptions()
|
||||||
|
if not subs:
|
||||||
|
return
|
||||||
|
payload = json.dumps({'job': job})
|
||||||
|
gone = []
|
||||||
|
for endpoint, entry in subs.items():
|
||||||
|
try:
|
||||||
|
webpush(entry['subscription'], payload,
|
||||||
|
vapid_private_key=VAPID_KEY_FILE,
|
||||||
|
vapid_claims={'sub': VAPID_SUBJECT},
|
||||||
|
ttl=24 * 3600)
|
||||||
|
except WebPushException as e:
|
||||||
|
if e.response is not None and e.response.status_code in (404, 410):
|
||||||
|
gone.append(endpoint)
|
||||||
|
else:
|
||||||
|
logging.warning('Web push to %s failed: %s', endpoint[:60], e)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning('Web push to %s failed: %s', endpoint[:60], e)
|
||||||
|
if gone:
|
||||||
|
with push_lock:
|
||||||
|
subs = _load_push_subscriptions()
|
||||||
|
for endpoint in gone:
|
||||||
|
subs.pop(endpoint, None)
|
||||||
|
_save_push_subscriptions(subs)
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_legacy_structure():
|
def _cleanup_legacy_structure():
|
||||||
"""Remove dated folders and symlink from the old /data/YYMMDD layout,
|
"""Remove dated folders and symlink from the old /data/YYMMDD layout,
|
||||||
migrating any files up to /data/{county}/ before deleting."""
|
migrating any files up to /data/{county}/ before deleting."""
|
||||||
@@ -231,11 +318,8 @@ def _cleanup_legacy_structure():
|
|||||||
_cleanup_legacy_structure()
|
_cleanup_legacy_structure()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
def _list_data_files(counties):
|
||||||
def index():
|
"""Files in each county's data folder, newest first: {county_id: [{name, ts}]}."""
|
||||||
"""Main index page with script execution buttons"""
|
|
||||||
counties = get_counties()
|
|
||||||
|
|
||||||
data_files = {}
|
data_files = {}
|
||||||
for county in counties:
|
for county in counties:
|
||||||
county_dir = f'/data/{county["id"]}'
|
county_dir = f'/data/{county["id"]}'
|
||||||
@@ -244,16 +328,29 @@ def index():
|
|||||||
for fname in sorted(os.listdir(county_dir)):
|
for fname in sorted(os.listdir(county_dir)):
|
||||||
fpath = os.path.join(county_dir, fname)
|
fpath = os.path.join(county_dir, fname)
|
||||||
if os.path.isfile(fpath):
|
if os.path.isfile(fpath):
|
||||||
mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime('%Y-%m-%d %H:%M')
|
files.append({'name': fname, 'ts': int(os.path.getmtime(fpath))})
|
||||||
files.append({'name': fname, 'mtime': mtime})
|
|
||||||
if files:
|
if files:
|
||||||
|
files.sort(key=lambda f: f['ts'], reverse=True)
|
||||||
data_files[county['id']] = files
|
data_files[county['id']] = files
|
||||||
|
return data_files
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/data-files')
|
||||||
|
def data_files_api():
|
||||||
|
return jsonify(_list_data_files(get_counties()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Main index page with script execution buttons"""
|
||||||
|
counties = get_counties()
|
||||||
|
data_files = _list_data_files(counties)
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
return render_template('index.html',
|
return render_template('index.html',
|
||||||
counties=counties,
|
counties=counties,
|
||||||
counties_json=_json.dumps(counties),
|
counties_json=_json.dumps(counties),
|
||||||
data_files=data_files,
|
data_files_json=_json.dumps(data_files),
|
||||||
current_user=session.get('username'))
|
current_user=session.get('username'))
|
||||||
|
|
||||||
@app.route('/map')
|
@app.route('/map')
|
||||||
@@ -483,10 +580,13 @@ def run_script():
|
|||||||
return jsonify({'error': 'Invalid script configuration'}), 400
|
return jsonify({'error': 'Invalid script configuration'}), 400
|
||||||
|
|
||||||
job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
||||||
|
county_name = next((c['name'] for c in get_counties() if c['id'] == county), county)
|
||||||
job = {
|
job = {
|
||||||
'id': job_id,
|
'id': job_id,
|
||||||
'script': script_name,
|
'script': script_name,
|
||||||
|
'label': str(data.get('label') or script_name)[:100],
|
||||||
'county': county,
|
'county': county,
|
||||||
|
'county_name': county_name,
|
||||||
'user': session.get('username'),
|
'user': session.get('username'),
|
||||||
'started': _utcnow(),
|
'started': _utcnow(),
|
||||||
'ended': None,
|
'ended': None,
|
||||||
@@ -540,6 +640,7 @@ def run_script():
|
|||||||
_save_job(job)
|
_save_job(job)
|
||||||
# Finished logs live on disk; don't hold them in memory forever.
|
# Finished logs live on disk; don't hold them in memory forever.
|
||||||
process_logs.pop(job_id, None)
|
process_logs.pop(job_id, None)
|
||||||
|
_notify_job_finished(job)
|
||||||
|
|
||||||
thread = threading.Thread(target=run_command)
|
thread = threading.Thread(target=run_command)
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
@@ -592,6 +693,54 @@ def cancel_job():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker():
|
||||||
|
"""Served from the site root so the worker's scope covers every page."""
|
||||||
|
resp = make_response(send_from_directory(app.static_folder, 'sw.js'))
|
||||||
|
resp.headers['Content-Type'] = 'application/javascript'
|
||||||
|
resp.headers['Cache-Control'] = 'no-cache'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@app.route('/manifest.webmanifest')
|
||||||
|
def web_manifest():
|
||||||
|
"""Lets phones install the dashboard; iOS only allows web push for installed apps."""
|
||||||
|
resp = make_response(send_from_directory(app.static_folder, 'manifest.webmanifest'))
|
||||||
|
resp.headers['Content-Type'] = 'application/manifest+json'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@app.route('/api/push/key')
|
||||||
|
@require_auth
|
||||||
|
def push_key():
|
||||||
|
if not _has_webpush:
|
||||||
|
return jsonify({'error': 'pywebpush not installed on the server'}), 503
|
||||||
|
return jsonify({'publicKey': _vapid_public_key()})
|
||||||
|
|
||||||
|
@app.route('/api/push/subscribe', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def push_subscribe():
|
||||||
|
sub = (request.json or {}).get('subscription') or {}
|
||||||
|
keys = sub.get('keys') or {}
|
||||||
|
if not (isinstance(sub.get('endpoint'), str) and sub['endpoint'].startswith('https://')
|
||||||
|
and keys.get('p256dh') and keys.get('auth')):
|
||||||
|
return jsonify({'error': 'Invalid subscription'}), 400
|
||||||
|
with push_lock:
|
||||||
|
subs = _load_push_subscriptions()
|
||||||
|
subs[sub['endpoint']] = {'subscription': {'endpoint': sub['endpoint'], 'keys': {
|
||||||
|
'p256dh': keys['p256dh'], 'auth': keys['auth']}},
|
||||||
|
'user': session.get('username'), 'created': _utcnow()}
|
||||||
|
_save_push_subscriptions(subs)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@app.route('/api/push/unsubscribe', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def push_unsubscribe():
|
||||||
|
endpoint = (request.json or {}).get('endpoint')
|
||||||
|
with push_lock:
|
||||||
|
subs = _load_push_subscriptions()
|
||||||
|
if subs.pop(endpoint, None) is not None:
|
||||||
|
_save_push_subscriptions(subs)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
@app.route('/api/upload-file', methods=['POST'])
|
@app.route('/api/upload-file', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def upload_file():
|
def upload_file():
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "OSM Import Tools",
|
||||||
|
"short_name": "OSM Import",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a2e",
|
||||||
|
"theme_color": "#1a1a2e"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Service worker: shows a notification when the server pushes a finished job,
|
||||||
|
// and focuses (or opens) the dashboard when the notification is clicked.
|
||||||
|
|
||||||
|
const STATUS_TEXT = {
|
||||||
|
success: 'succeeded', failed: 'failed', cancelled: 'was cancelled', interrupted: 'was interrupted'
|
||||||
|
};
|
||||||
|
const STATUS_ICON = { success: '✓', failed: '✗', cancelled: '⊘', interrupted: '⚠' };
|
||||||
|
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
let job = {};
|
||||||
|
try { job = (event.data && event.data.json()).job || {}; } catch (e) { /* show a generic notice */ }
|
||||||
|
const status = STATUS_TEXT[job.status] || 'finished';
|
||||||
|
const failed = job.status === 'failed' && job.exit_code !== null && job.exit_code !== undefined;
|
||||||
|
const where = job.county ? ` (${job.county_name || job.county})` : '';
|
||||||
|
const title = `${STATUS_ICON[job.status] || ''} ${job.label || job.script || 'Job'}${where} ${status}`.trim();
|
||||||
|
const body = (failed ? `Exit code ${job.exit_code}. ` : '') +
|
||||||
|
(job.user ? `Started by ${job.user}. ` : '') + 'Tap to view the output.';
|
||||||
|
event.waitUntil(self.registration.showNotification(title, {
|
||||||
|
body,
|
||||||
|
tag: job.id || 'job',
|
||||||
|
data: { url: '/', jobId: job.id },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin).href;
|
||||||
|
event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(wins => {
|
||||||
|
const win = wins.find(w => new URL(w.url).pathname === '/');
|
||||||
|
return win ? win.focus() : clients.openWindow(url);
|
||||||
|
}));
|
||||||
|
});
|
||||||
+327
-29
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OSM Import Tools</title>
|
<title>OSM Import Tools</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
@@ -255,22 +257,60 @@
|
|||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-links {
|
.files-strip { max-height: 38vh; overflow-y: auto; }
|
||||||
display: flex;
|
.files-strip[hidden] { display: none; }
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
.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;
|
font-size: 10px;
|
||||||
padding: 2px 7px;
|
font-weight: 700;
|
||||||
border-radius: 3px;
|
color: #888;
|
||||||
color: white;
|
border-bottom: 1px solid #d8dbe0;
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 panel (console) ── */
|
||||||
.right {
|
.right {
|
||||||
@@ -312,6 +352,48 @@
|
|||||||
|
|
||||||
.btn-cancel.active { display: inline-block; }
|
.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 {
|
#status {
|
||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -492,7 +574,11 @@
|
|||||||
|
|
||||||
.upload-input { width: 100%; max-width: 200px; }
|
.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 {
|
.right {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
@@ -659,24 +745,18 @@
|
|||||||
|
|
||||||
</div><!-- /workflows -->
|
</div><!-- /workflows -->
|
||||||
|
|
||||||
<!-- Data files strip -->
|
<!-- Data files strip (rows rendered by renderFiles()) -->
|
||||||
{% if data_files %}
|
<div class="files-strip" id="filesStrip" hidden>
|
||||||
<div class="files-strip">
|
|
||||||
<div class="files-strip-title">Downloaded files</div>
|
<div class="files-strip-title">Downloaded files</div>
|
||||||
<div class="file-links">
|
<table class="files-table" id="filesTable">
|
||||||
{% for county in counties %}
|
<thead>
|
||||||
{% if county.id in data_files %}
|
<tr>
|
||||||
{% for file in data_files[county.id] %}
|
<th class="col-name"><button type="button" data-sort="name">File</button></th>
|
||||||
<a class="file-link"
|
<th class="col-date" aria-sort="descending"><button type="button" data-sort="ts">Modified</button></th>
|
||||||
style="--link-color: {{ county.color }}"
|
</tr>
|
||||||
href="/data/{{ county.id }}/{{ file.name }}"
|
</thead>
|
||||||
title="{{ county.id }}/{{ file.name }} — {{ file.mtime }}">{{ file.name }}</a>
|
</table>
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div><!-- /left -->
|
</div><!-- /left -->
|
||||||
|
|
||||||
@@ -684,9 +764,17 @@
|
|||||||
<div class="right">
|
<div class="right">
|
||||||
<div class="console-header">
|
<div class="console-header">
|
||||||
<span>Output</span>
|
<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>
|
<button id="cancelButton" class="btn-cancel" onclick="cancelJob()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="jobBanner" role="status" aria-live="polite"></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="status"></div>
|
||||||
<div id="sys-stats">
|
<div id="sys-stats">
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
@@ -721,6 +809,7 @@
|
|||||||
let lastSeenLatestId = null;
|
let lastSeenLatestId = null;
|
||||||
let jobsTimer = null;
|
let jobsTimer = null;
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
|
let finishedJobsKey = null; // finished job ids last seen, to spot completions
|
||||||
|
|
||||||
function requireLogin() {
|
function requireLogin() {
|
||||||
showStatus('Not authenticated – please log in.', 'error');
|
showStatus('Not authenticated – please log in.', 'error');
|
||||||
@@ -786,6 +875,79 @@
|
|||||||
// Init
|
// Init
|
||||||
if (activeCounty) selectCounty(activeCounty);
|
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 ── */
|
/* ── Script runner ── */
|
||||||
function runScript(scriptName) {
|
function runScript(scriptName) {
|
||||||
if (!IS_AUTHENTICATED) return requireLogin();
|
if (!IS_AUTHENTICATED) return requireLogin();
|
||||||
@@ -805,7 +967,12 @@
|
|||||||
fetch('/api/run-script', {
|
fetch('/api/run-script', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -817,6 +984,7 @@
|
|||||||
followLatest = true;
|
followLatest = true;
|
||||||
lastSeenLatestId = data.job_id;
|
lastSeenLatestId = data.job_id;
|
||||||
viewJob(data.job_id, true);
|
viewJob(data.job_id, true);
|
||||||
|
maybeOfferNotifications();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showStatus(`Error: ${err.message}`, 'error');
|
showStatus(`Error: ${err.message}`, 'error');
|
||||||
@@ -951,6 +1119,11 @@
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) return;
|
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;
|
jobsData = data;
|
||||||
renderStepStatus();
|
renderStepStatus();
|
||||||
if (data.running.length) disableButtons(); else if (!uploading) enableButtons();
|
if (data.running.length) disableButtons(); else if (!uploading) enableButtons();
|
||||||
@@ -979,7 +1152,131 @@
|
|||||||
jobsTimer = setTimeout(refreshJobs, busy ? 1000 : 5000);
|
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) {
|
if (IS_AUTHENTICATED) {
|
||||||
|
initNotifications();
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshJobs(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshJobs(); });
|
||||||
} else {
|
} else {
|
||||||
@@ -1009,6 +1306,7 @@
|
|||||||
showStatus(`Error: ${data.error}`, 'error');
|
showStatus(`Error: ${data.error}`, 'error');
|
||||||
} else {
|
} else {
|
||||||
showStatus(`Uploaded to ${data.path}`, 'success');
|
showStatus(`Uploaded to ${data.path}`, 'success');
|
||||||
|
refreshFiles();
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user