Sortable per-county downloaded files table that refreshes when jobs finish, and web push notifications for job completion

This commit is contained in:
zyphlar
2026-10-16 20:18:39 -07:00
parent 96d5b9bac8
commit 4aca4c96fd
7 changed files with 539 additions and 39 deletions
+6
View File
@@ -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:
+1
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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():
+9
View File
@@ -0,0 +1,9 @@
{
"name": "OSM Import Tools",
"short_name": "OSM Import",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e"
}
+32
View File
@@ -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
View File
@@ -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 = '';
} }
}) })