/* ---------------- 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, directory: drawDirectorySettings, 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); } }; } /* ---------------- directory ---------------- */ // The directory service the Directory shows (#205): where it is, this server's token for it, and // whether to share. The token is sent, never shown: the server says only whether one is set. const ACCESS: Record = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'}; async function drawDirectorySettings(){ const box = $('#directory'); const g = await api('/api/settings'); box.innerHTML = `

Directory

The Directory shows podcasts from a directory service that iPX servers share. Without one, people add feeds by their address.

${g.directory_token_set ? `` : ''}
Whoever runs the directory makes one for each server, and can revoke it.
Sends the directory the feeds people here subscribe to, and how many of them do, every half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.

Asking the directory…

`; const save = async (patch: Record, said: string) => { try{ await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)}); toast(said); drawDirectorySettings(); }catch(e){ toast(e.message, true); } }; $('#dsave').onclick = () => { const patch: Record = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked}; // Blank leaves the token as it is; Clear is the way to remove it. if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim(); save(patch, 'Directory settings saved'); }; const clear = $('#dclear'); if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared'); drawDirectoryStatus(); } async function drawDirectoryStatus(){ const el = $('#dstatus'); let s; try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; } if(!el.isConnected) return; const lines: string[] = []; if(!s.configured) lines.push('Not set up: it needs an address and a token.'); else if(s.error) lines.push(esc(s.error)); else lines.push(`Connected as ${esc(s.name)}, which may ${esc(ACCESS[s.access] || s.access)}.`); const sh = s.shared; if(sh){ const when = new Date(sh.at * 1000).toLocaleString(); lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}` : `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` + (sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') + (sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : '')); } el.innerHTML = lines.join('
'); if(s.access === 'rwa') drawDirectoryAdmin(); } /// What a token that may administer the directory can change from here (#206): the servers /// sharing with it, and the feeds removed from it. async function drawDirectoryAdmin(){ const box = $('#dadmin'); let a; try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `

${esc(e.message)}

`; return; } if(!box.isConnected) return; box.innerHTML = `

Servers

Each server sharing with the directory, by its token's name. Dropping one removes its feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.

${a.instances.length ? a.instances.map(i => `
${esc(i.name)} ${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}
`).join('') : '

No server is sharing yet.

'}

Blocked

Feeds removed from the directory. No snapshot brings them back until they are unblocked.

${a.blocked.length ? a.blocked.map(b => `
${esc(b.title || 'Untitled')} Removed ${dateOf(b.at)}
`).join('') : '

Nothing is blocked.

'}`; box.onclick = async ev => { const b = (ev.target as Element).closest('[data-a]'); if(!b) return; const row = b.closest('.urow'); try{ if(b.dataset.a === 'drop'){ const name = row.dataset.name; if(!confirm(`Drop ${name}'s feeds and counts from the directory? They come back the next time ${name} shares.`)) return; await api(`/api/directory-instances/${encodeURIComponent(name)}`, {method: 'DELETE'}); toast(`Dropped ${name}'s snapshot`); }else if(b.dataset.a === 'unblock'){ await api(`/api/directory-blocked/${encodeURIComponent(row.dataset.id)}`, {method: 'DELETE'}); toast('Unblocked. It comes back with the next snapshot that has it'); } drawDirectoryAdmin(); }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();