/* ---------------- admin page ---------------- */ // /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's // hash so a link can go straight to one. The server sends this page and this script to admins // only, and refuses every call below to anyone else; they were parts of Settings and the header, // shown or hidden by the main page's script (issue #19). let me: {name: string} | null = null; api('/api/me').then(u => { me = u; }).catch(() => {}); const SECTIONS: Record void> = {server: drawServer, accounts: drawAccounts, log: drawLogView}; function showSection(){ const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server'; for(const s of $$('#admin > section')) s.hidden = s.id !== t; for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t); // The log polls every two seconds while it is showing, and not otherwise. if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; } SECTIONS[t](); } window.addEventListener('hashchange', showSection); /* ---------------- server ---------------- */ async function drawServer(){ const box = $('#server'); const g = await api('/api/settings'); const gs = splitEvery(g.every_mins); box.innerHTML = `

Server

These apply to everyone. Each person's own choices, such as keywords or how many items a feed downloads for them, are in that feed's settings.

Applies to every feed that does not set its own. A feed's suggested interval (its ttl) is still honoured when it asks to be polled less often.
Applies to any feed that does not set its own — including every feed inside an OPML subscription. 0 means unlimited, which will pull a whole back catalogue the first time a feed is scanned.
Anything else is still listed and can be downloaded by hand — blog feeds put article images in enclosures, and those are not worth keeping. Empty takes everything.
Over this, the oldest played items are deleted first. Pinned items are never touched.
${esc(g.download_dir)}
`; $('#gsave').onclick = async () => { try{ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`, max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), max_total_gb: Number($('#gquota').value) || 0, max_age_days: Number($('#gage').value) || 0})}); toast('Settings saved'); }catch(e){ toast(e.message, true); } }; } /* ---------------- accounts ---------------- */ async function drawAccounts(){ const box = $('#accounts'); const users = await api('/api/users') || []; box.innerHTML = `

Accounts

${users.map(u => `
${esc(u.name)} ${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}
${u.password ? '' : 'Proxy'}
`).join('')}
At least 8 characters. Leave the password empty for someone who signs in through the proxy. New people start with no feeds.
`; const change = async (u, opts) => { try{ await api(`/api/users/${u.id}`, opts); // Demoting yourself takes this page away; go back to the app rather than stay on a page // the server no longer answers. Only on success: a refusal's toast has to stay readable. if(u.name === me?.name){ location.href = '/'; return; } }catch(e){ toast(e.message, true); } drawAccounts(); // on a refusal, this puts the checkbox back where the server left it }; for(const row of $$('#accounts [data-id]')){ const u = users.find(x => String(x.id) === row.dataset.id); $('[data-a="admin"]', row).onchange = e => change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})}); $('[data-a="rm"]', row).onclick = () => { if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`)) change(u, {method: 'DELETE'}); }; } $('#uadd').onclick = async () => { try{ await api('/api/users', {method: 'POST', body: JSON.stringify({ name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})}); toast('Added'); drawAccounts(); }catch(e){ toast(e.message, true); } // keep what was typed }; } /* ---------------- log ---------------- */ let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all'; // Which sources belong to each tab. "daemon" is the control protocol itself: every // command in and every event out, whatever sent it. const LOG_TABS = { all: null, daemon: t => t === 'ipx::io', scan: t => t === 'ipx::scan', web: t => t === 'ipx::http', }; const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0}; function drawLogView(){ logSeq = 0; logLines = []; $('#log').innerHTML = `

Log

${Object.keys(LOG_TABS).map(t => ``).join('')}

Loading…

Daemon I/O is the control protocol itself — every command in and every event out. Scans is feed and download activity, HTTP is web requests. The buffer keeps debug detail even when the terminal does not; IPX_UI_LOG changes what it captures.`; for(const b of $$('#logtabs button')) b.onclick = () => { logTab = b.dataset.t; for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); drawLog(); }; $('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); }; $('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); }; $('#logcopy').onclick = () => copyText(visibleLog().map(l => `${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy')); pollLog(); if(!logTimer) logTimer = setInterval(pollLog, 2000); } async function pollLog(){ try{ const r = await api(`/api/logs?after=${logSeq}&limit=500`); if(r.lines.length){ logLines = logLines.concat(r.lines).slice(-2000); logSeq = r.latest; drawLog(); }else if(!logLines.length){ drawLog(); } }catch(e){ const box = $('#logbox'); if(box) box.innerHTML = `

Lost contact with the daemon: ${esc(e.message)}

`; } } function visibleLog(){ const min = logLevel ? LEVELS[logLevel] : -1; const tab = LOG_TABS[logTab]; return logLines.filter(l => (!tab || tab(l.target)) && (LEVELS[l.level] ?? 1) >= min && (!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter))); } function drawLog(){ const box = $('#logbox'); if(!box) return; const follow = $('#logfollow')?.checked; const rows = visibleLog(); box.innerHTML = rows.length ? rows.map(l => { const t = new Date(l.ts * 1000).toLocaleTimeString(); if(logTab === 'daemon'){ const out = l.msg.startsWith('<-'); return `
` + `${out ? 'out' : 'in'}` + `${esc(l.msg.replace(/^[<-]+\s*/, ''))}
`; } return `
${esc(l.level)}` + `${esc(l.target.replace(/^ipx::?/, ''))}${esc(l.msg)}
`; }).join('') : '

Nothing matches.

'; if(follow) box.scrollTop = box.scrollHeight; } showSection();