/* ---------------- 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); // The parts of each section named in a column beside their controls, and on or off drawn as a // switch, as a feed's settings are (#290, #414): the page had grown one captioned box at a time. const asect = (title: string, body: string) => `

${title}

${body}
`; const aswitch = (input: string, text: string) => ``; /// A number with its unit after it, rather than in the label. const anum = (id: string, label: string, value: number, unit: string, attrs = '') => `
${unit}
`; /* ---------------- 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.

${asect('Feeds', `

For every feed that does not set its own. A feed that asks to be checked less often (its ttl) still is.

${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')}

For every feed that does not set its own, those inside an OPML subscription too. Older ones stay listed, to download by hand. 0 downloads every episode.

Anything else is listed, to download by hand: blog feeds put article images in enclosures. Empty takes everything.

`)} ${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')}

0 has no limit. Over it, the oldest played items go first; pinned ones never do.

${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')}

0 keeps them.

${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')}

Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.

Download folder ${esc(g.download_dir)}
`)} ${asect('Signals', `${aswitch(``, 'On this server\'s key, for everyone')}

${g.typesafe_server_key ? 'On, every item read for a Signal is a call on this server\'s TypeSafe key. Off, each person turns Signals on in their own Settings, with their own key.' : 'This server has no TypeSafe key (TYPESAFE_KEY), so each person uses their own.'}

${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')}

Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.

`)}
`; // Save waits for a change, so it says whether there is one. box.oninput = () => { $('#gsave').disabled = false; }; $('#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: string) => 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), signal_days: Math.min(365, Math.max(1, Number($('#gsigdays').value) || 7)), // Left alone while the server has no key to offer, rather than switched off. ...($('#gsig').disabled ? {} : {signals_on_server: $('#gsig').checked})})}); toast('Settings saved'); $('#gsave').disabled = true; }catch(e){ toast(why(e), 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.

Asking the directory…

${asect('Connection', `
${g.directory_token_set ? `` : ''}

Whoever runs the directory makes one for each server, and can revoke it.

${aswitch(``, 'Share this server\'s public feeds')}

Every half hour, the feeds people here subscribe to and how many do. Never a private feed: one marked private, one with a login, or one whose address carries a key.

`)}
`; const save = async (patch: Record, said: string) => { try{ await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)}); toast(said); drawDirectorySettings(); }catch(e){ toast(why(e), 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 = why(e); 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: DirAdmin; try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `

${esc(why(e))}

`; return; } if(!box.isConnected) return; box.innerHTML = asect('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.

'}`) + asect('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: MouseEvent) => { 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(why(e), true); } }; } /* ---------------- accounts ---------------- */ async function drawAccounts(){ const box = $('#accounts'); const users: Account[] = await api('/api/users') || []; box.innerHTML = `

Accounts

${asect('People', users.map(u => `
${esc(u.name)}${u.password ? '' : ' Proxy'} ${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}
${aswitch(``, 'Admin')}
`).join(''))} ${asect('Add someone', `

At least 8 characters. Empty for someone who signs in through the proxy.

${aswitch('', 'Admin')}

New people start with no feeds.

`)}`; const change = async (u: Account, opts: RequestInit) => { 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(why(e), 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: Event) => change(u, {method: 'PATCH', body: JSON.stringify({admin: (e.target as HTMLInputElement).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(why(e), true); } // keep what was typed }; } /* ---------------- log ---------------- */ let logTimer: number|null = null, logSeq = 0, logLines: LogLine[] = [], 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: Record boolean)|null> = { all: null, daemon: t => t === 'ipx::io', scan: t => t === 'ipx::scan', web: t => t === 'ipx::http', }; // Said on each tab, rather than in a paragraph under the log. const LOG_TAB_SAYS: Record = { all: 'Everything', daemon: 'The control protocol: every command in and every event out', scan: 'Feed and download activity', web: 'Web requests', }; const LEVELS: Record = {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('')}
${searchBox('','grow')}

Loading…

Debug lines are kept here even when the container's log leaves them out; IPX_UI_LOG changes what is kept.

`; 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: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); }; $('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).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(why(e))}

`; } } 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();