diff --git a/mail.js b/mail.js index f3b5f51..f334cc9 100644 --- a/mail.js +++ b/mail.js @@ -335,4 +335,8 @@ async function newest() { for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } } return out; } -module.exports = { newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; +/** For the admin health page: can each connected account be reached right now? */ +async function health() { + return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } })); +} +module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; diff --git a/public/settings.js b/public/settings.js index 62997ca..3c9af7b 100644 --- a/public/settings.js +++ b/public/settings.js @@ -92,6 +92,7 @@ function viewSettings() {
${isAdmin ? `

People

Loading…
+

Health

Checking…

Storage

Checking…
Download a backup

A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.

` : ''} ${isAdmin ? `

Activity

Loading…
` : ''} @@ -128,6 +129,17 @@ function viewSettings() { if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; } }).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; }); if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); } + if (isAdmin) jfetch('/api/health').then(h => { + const row = (t, d, bad) => `
${bad ? '⚠ ' : '✓ '}${t}${d}
`; + const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5); + const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min'; + $('#health').innerHTML = + (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.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.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) + + row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false); + }).catch(() => { const e = $('#health'); if (e) e.innerHTML = '
Could not read the health report
'; }); if (isAdmin) jfetch('/api/system').then(i => { const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable; const row = (t, d, bad) => `
${t}${d}
`; diff --git a/public/sw.js b/public/sw.js index ade7fed..2b6696c 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-v21'; +const CACHE = 'hub-v22'; 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/server.js b/server.js index 3adac92..ae6af5d 100644 --- a/server.js +++ b/server.js @@ -589,7 +589,7 @@ async function handleRequest(req, res) { return users.runAs(user, async () => { const rule = ACCESS.find(([re]) => re.test(pathname)); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); - if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); + if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); } if (pathname.startsWith('/api/users')) return admin.handle(req, res, user); if (req.url === '/api/config') { // only reveal the addresses of things this person may use @@ -603,6 +603,17 @@ async function handleRequest(req, res) { if (pathname === '/api/ddg') return ddgAnswer(req, res); if (pathname.startsWith('/api/push/')) return push.handle(req, res); if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); } + if (pathname === '/api/health') { // everything an admin wants to know at a glance + const fs = require('fs'), dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), out = { at: Date.now() }; + try { const f = fs.statfsSync(dir); out.disk = { free: f.bavail * f.bsize, total: f.blocks * f.bsize }; } catch { out.disk = null; } + const bk = require('./audit').recent(2000).find(x => x.event === 'backup downloaded'); out.backup = bk ? { t: bk.t, who: bk.who } : null; + out.uptime = Math.round((Date.now() - STARTED) / 1000); out.node = process.version; + const ps = plexLoad(); + out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })(); + try { out.mail = await mail.health(); } catch { out.mail = []; } + out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out)); + } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/settings') return settings.handle(req, res); if (pathname === '/api/search') return search.handle(req, res);