From c4b830dd435c97e79de97805c7e02060a9672ec4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:52:05 -0400 Subject: [PATCH] Admin health: push devices, pushes sent, expired devices and the last failure Co-Authored-By: Claude Sonnet 5.5 --- public/settings.js | 1 + public/sw.js | 2 +- push.js | 16 +++++++++++++--- server.js | 1 + tools/push-test.js | 2 ++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/public/settings.js b/public/settings.js index c338a6e..3c94ac6 100644 --- a/public/settings.js +++ b/public/settings.js @@ -158,6 +158,7 @@ function viewSettings(args) { (h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false)) + (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('') + (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false)) + + (h.push ? row('Reminders and push', h.push.devices ? h.push.devices + ' device' + (h.push.devices === 1 ? '' : 's') + ' for ' + h.push.people + (h.push.people === 1 ? ' person' : ' people') + ' · ' + h.push.sent + ' sent since the server started' + (h.push.expired ? ' · ' + h.push.expired + ' expired device' + (h.push.expired === 1 ? '' : 's') + ' removed' : '') + (h.push.failed ? ' · ' + h.push.failed + ' failed (last: ' + esc(h.push.lastErr) + ')' : '') : 'No device has turned reminders on yet', h.push.failed > 0 && h.push.lastErrAt > h.push.lastOk) : '') + (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '') + row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30) + (h.transcripts && h.transcripts.enabled ? row('Transcripts', `${h.transcripts.saved} saved · ${h.transcripts.working ? 'working on ' + h.transcripts.working : 'idle'}${h.transcripts.failed ? ' · ' + h.transcripts.failed + ' failed recently' : ''} · ${h.transcripts.auto ? 'automatic for the newest ' + h.transcripts.keep + ' of each show' : 'automatic transcribing is off'}`, false) : '') diff --git a/public/sw.js b/public/sw.js index 1ba2ff5..4e8cec1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v139'; +const CACHE = 'hub-v140'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/push.js b/push.js index 3d87981..006e775 100644 --- a/push.js +++ b/push.js @@ -36,13 +36,23 @@ function localNow(tz, at = new Date()) { return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` }; } +// Since the server started: how many pushes went out, and what the push service last refused (shown to the admin in Settings > Admin) +const stats = { sent: 0, failed: 0, expired: 0, lastOk: 0, lastErr: '', lastErrAt: 0 }; +function health() { + let devices = 0, people = 0; + for (const u of users.all()) { if (u.disabled) continue; const n = load(users.dirOf(u.id)).subs.length; devices += n; if (n) people++; } + return { ...stats, devices, people }; +} async function send(dir, st, uid, payload) { if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list) keys(); const { ref, ...wire } = payload; let changed = false; await Promise.all(st.subs.map(async s => { - try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); } - catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); } + try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); stats.sent++; stats.lastOk = Date.now(); } + catch (e) { + if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; stats.expired++; } + else { stats.failed++; stats.lastErr = String(e.statusCode || e.message).slice(0, 120); stats.lastErrAt = Date.now(); console.warn('[push] send failed:', e.statusCode || e.message); } + } })); if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); } } @@ -182,4 +192,4 @@ async function handle(req, res) { } /** A notification for one person's devices (a chat message, say), unless they turned that kind off. */ async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, null, payload); } -module.exports = { handle, start, tick, localNow, notify }; +module.exports = { handle, start, tick, localNow, notify, health }; diff --git a/server.js b/server.js index bd15492..57b9407 100644 --- a/server.js +++ b/server.js @@ -687,6 +687,7 @@ async function handleRequest(req, res) { const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)]; if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about')); out.services = await Promise.all(checks); + try { out.push = require('./push').health(); } catch { out.push = null; } out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status(); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out)); } diff --git a/tools/push-test.js b/tools/push-test.js index ec910a8..0cf4916 100644 --- a/tools/push-test.js +++ b/tools/push-test.js @@ -78,6 +78,8 @@ const users = require('../users'), push = require('../push'); dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] })); await push.tick(new Date()); ok('a device that has unsubscribed (410) is removed', JSON.parse(fs.readFileSync(path.join(d, 'push.json'), 'utf8')).subs.length === 0, fs.readFileSync(path.join(d, 'push.json'), 'utf8')); + const h = push.health(); + ok('the admin health report counts the pushes sent and the expired device removed', h.sent > 0 && h.expired >= 1 && typeof h.devices === 'number' && typeof h.people === 'number', h); fs.rmSync(dir, { recursive: true, force: true }); console.log(failed ? `\n${failed} check(s) failed` : '\nAll push checks passed'); process.exit(failed ? 1 : 0);