/* ---------------- 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. Older episodes stay listed to download by hand. 0 means every episode, the whole back catalogue.
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.
Show and episode artwork is kept here once shown, so it loads from iPX instead of each publisher. Over this, what has gone longest unshown is dropped first.
${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, art_cache_mb: Math.max(0, Number($('#gart').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();