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
- 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
The Docker container mounts a single data directory:
+1
View File
@@ -7,3 +7,4 @@ shapely>=2.0.0
pyproj>=3.6.0
rtree>=1.1.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.
# 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
# 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
"""
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
import subprocess
import os
@@ -13,6 +13,8 @@ import shlex
import shutil
import threading
import json
import base64
import logging
from datetime import datetime, timezone
import yaml
try:
@@ -20,6 +22,13 @@ try:
_has_psutil = True
except ImportError:
_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')
@@ -201,6 +210,84 @@ def _job_logs(job_id):
_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():
"""Remove dated folders and symlink from the old /data/YYMMDD layout,
migrating any files up to /data/{county}/ before deleting."""
@@ -231,11 +318,8 @@ def _cleanup_legacy_structure():
_cleanup_legacy_structure()
@app.route('/')
def index():
"""Main index page with script execution buttons"""
counties = get_counties()
def _list_data_files(counties):
"""Files in each county's data folder, newest first: {county_id: [{name, ts}]}."""
data_files = {}
for county in counties:
county_dir = f'/data/{county["id"]}'
@@ -244,16 +328,29 @@ def index():
for fname in sorted(os.listdir(county_dir)):
fpath = os.path.join(county_dir, fname)
if os.path.isfile(fpath):
mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime('%Y-%m-%d %H:%M')
files.append({'name': fname, 'mtime': mtime})
files.append({'name': fname, 'ts': int(os.path.getmtime(fpath))})
if files:
files.sort(key=lambda f: f['ts'], reverse=True)
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
return render_template('index.html',
counties=counties,
counties_json=_json.dumps(counties),
data_files=data_files,
data_files_json=_json.dumps(data_files),
current_user=session.get('username'))
@app.route('/map')
@@ -483,10 +580,13 @@ def run_script():
return jsonify({'error': 'Invalid script configuration'}), 400
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 = {
'id': job_id,
'script': script_name,
'label': str(data.get('label') or script_name)[:100],
'county': county,
'county_name': county_name,
'user': session.get('username'),
'started': _utcnow(),
'ended': None,
@@ -540,6 +640,7 @@ def run_script():
_save_job(job)
# Finished logs live on disk; don't hold them in memory forever.
process_logs.pop(job_id, None)
_notify_job_finished(job)
thread = threading.Thread(target=run_command)
thread.daemon = True
@@ -592,6 +693,54 @@ def cancel_job():
except Exception as e:
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'])
@require_auth
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 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 %}
<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>
</div>
{% endif %}
</div><!-- /left -->
@@ -684,9 +764,17 @@
<div class="right">
<div class="console-header">
<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>
</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 = '';
}
})