Files
ipx/web/src/admin.ts
rays c6dcb7f34e Admin page laid out as a feed's settings are (#414)
Prototype for approval on the issue; not to be merged until then.

The page had grown one captioned box at a time: at 390 px the tabs ran off
the edge and hid Log, Server was eleven full-width boxes (1150 px wide on a
desktop for a one-digit number) with units and the meaning of 0 in the
labels, and Directory's Save sat between its settings and the lists below.

Each tab is now named parts in .fsec, the layout #290 gave a feed's settings,
with its switches, numbers with their unit after the box, Server's Save
sticky and greyed until a change, the tabs in their own row on a phone, the
Proxy tag by the account's name, and the log's explanation moved into each
source tab's tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:34:17 +00:00

326 lines
19 KiB
TypeScript

/* ---------------- 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<string, () => 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) => `<section class="fsec"><h4>${title}</h4><div class="fsecin">${body}</div></section>`;
const aswitch = (input: string, text: string) => `<label class="switch"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
/// A number with its unit after it, rather than in the label.
const anum = (id: string, label: string, value: number, unit: string, attrs = '') =>
`<div class="ffield"><label class="flabel" for="${id}">${label}</label>
<div class="inline"><input type="number" id="${id}" value="${value}"${attrs}><span class="unit">${unit}</span></div></div>`;
/* ---------------- server ---------------- */
async function drawServer(){
const box = $('#server');
const g = await api('/api/settings');
const gs = splitEvery(g.every_mins);
box.innerHTML = `<h2>Server</h2>
<p class="cap">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.</p>
${asect('Feeds', `<div class="ffield"><label class="flabel" for="gnum">Check every</label>
<div class="inline"><input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit" aria-label="Unit">${unitOptions(gs.u)}</select></div>
<p class="cap">For every feed that does not set its own. A feed that asks to be checked less
often (its <b>ttl</b>) still is.</p></div>
${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')}
<p class="cap">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.</p>
<div class="ffield"><label class="flabel" for="gtypes">Media types to download</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<p class="cap">Anything else is listed, to download by hand: blog feeds put article images in
enclosures. Empty takes everything.</p></div>`)}
${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')}
<p class="cap">0 has no limit. Over it, the oldest played items go first; pinned ones never do.</p>
${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')}
<p class="cap">0 keeps them.</p>
${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')}
<p class="cap">Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.</p>
<div class="ffield"><span class="flabel">Download folder</span>
<code class="apath">${esc(g.download_dir)}</code></div>`)}
${asect('Signals', `${aswitch(`<input type="checkbox" role="switch" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>`,
'On this server\'s key, for everyone')}
<p class="cap">${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 (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</p>
${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')}
<p class="cap">Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.</p>`)}
<div class="afoot"><button class="btn primary" id="gsave" disabled>${ICON.check} Save</button></div>`;
// Save waits for a change, so it says whether there is one.
box.oninput = () => { $<HTMLButtonElement>('#gsave').disabled = false; };
$<HTMLButtonElement>('#gsave').onclick = async () => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
schedule: `every ${Math.max(1, Number($<HTMLInputElement>('#gnum').value) || 1)}${$<HTMLSelectElement>('#gunit').value}`,
max_new_per_check: Math.max(0, Number($<HTMLInputElement>('#gmax').value) || 0),
media_types: $<HTMLInputElement>('#gtypes').value.split(',').map((t: string) => t.trim()).filter(Boolean),
max_total_gb: Number($<HTMLInputElement>('#gquota').value) || 0,
max_age_days: Number($<HTMLInputElement>('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($<HTMLInputElement>('#gart').value) || 0),
signal_days: Math.min(365, Math.max(1, Number($<HTMLInputElement>('#gsigdays').value) || 7)),
// Left alone while the server has no key to offer, rather than switched off.
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
toast('Settings saved');
$<HTMLButtonElement>('#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<string, string> = {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 = `<h2>Directory</h2>
<p class="cap">The Directory shows podcasts from a directory service that iPX servers share.
Without one, people add feeds by their address.</p>
<p class="astatus" id="dstatus" role="status">Asking the directory…</p>
${asect('Connection', `<div class="ffield"><label class="flabel" for="durl">Address</label>
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
<div class="ffield"><label class="flabel" for="dtoken">Access token</label>
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
<p class="cap">Whoever runs the directory makes one for each server, and can revoke it.</p></div>
${aswitch(`<input type="checkbox" role="switch" id="dshare"${g.directory_share ? ' checked' : ''}>`, 'Share this server\'s public feeds')}
<p class="cap">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.</p>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
<div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
toast(said);
drawDirectorySettings();
}catch(e){ toast(why(e), true); }
};
$<HTMLButtonElement>('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $<HTMLInputElement>('#durl').value.trim(), directory_share: $<HTMLInputElement>('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
if($<HTMLInputElement>('#dtoken').value.trim()) patch.directory_token = $<HTMLInputElement>('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
const clear = $<HTMLButtonElement>('#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 <b>${esc(s.name)}</b>, 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('<br>');
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 = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return;
box.innerHTML = asect('Servers', `<p class="cap">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.</p>
${a.instances.length ? a.instances.map(i => `<div class="urow" data-name="${esc(i.name)}">
<div class="uwho"><b>${esc(i.name)}</b><small>${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
<button class="btn ico danger" data-a="drop" title="Drop ${esc(i.name)}'s feeds" aria-label="Drop ${esc(i.name)}'s feeds">${ICON.trash}</button></div>`).join('')
: '<p class="cap">No server is sharing yet.</p>'}`) +
asect('Blocked', `<p class="cap">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
${a.blocked.length ? a.blocked.map(b => `<div class="urow" data-id="${esc(b.id)}">
<div class="uwho"><b>${esc(b.title || 'Untitled')}</b><small>Removed ${dateOf(b.at)}</small></div>
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="cap">Nothing is blocked.</p>'}`);
box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return;
const row = b.closest<HTMLElement>('.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 = `<h2>Accounts</h2>
${asect('People', users.map(u => `<div class="urow" data-id="${u.id}">
<div class="uwho"><b>${esc(u.name)}${u.password ? '' : ' <span class="tag" title="No password: signs in through the proxy">Proxy</span>'}</b>
<small>${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
${aswitch(`<input type="checkbox" role="switch" data-a="admin" ${u.admin ? 'checked' : ''}>`, 'Admin')}
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join(''))}
${asect('Add someone', `<div class="ffield"><label class="flabel" for="uname">Name</label>
<input type="text" id="uname" autocomplete="off" spellcheck="false"></div>
<div class="ffield"><label class="flabel" for="upass">Password</label>
<input type="password" id="upass" autocomplete="new-password">
<p class="cap">At least 8 characters. Empty for someone who signs in through the proxy.</p></div>
${aswitch('<input type="checkbox" role="switch" id="uadmin">', 'Admin')}
<p class="cap">New people start with no feeds.</p>
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`)}`;
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'});
};
}
$<HTMLButtonElement>('#uadd').onclick = async () => {
try{
await api('/api/users', {method: 'POST', body: JSON.stringify({
name: $<HTMLInputElement>('#uname').value, password: $<HTMLInputElement>('#upass').value, admin: $<HTMLInputElement>('#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<string, ((t: string) => 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<string, string> = {
all: 'Everything', daemon: 'The control protocol: every command in and every event out',
scan: 'Feed and download activity', web: 'Web requests',
};
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){
logSeq = 0; logLines = [];
$('#log').innerHTML = `<div class="loghead"><h2>Log</h2>
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}" title="${LOG_TAB_SAYS[t]}">${
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div></div>
<div class="logbar">
<select id="loglevel" aria-label="Level">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
${searchBox('<input type="search" id="logq" placeholder="Filter…" aria-label="Filter">','grow')}
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div>
<div id="logbox"><p class="empty">Loading…</p></div>
<p class="cap">Debug lines are kept here even when the container's log leaves them out;
<b>IPX_UI_LOG</b> changes what is kept.</p>`;
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();
};
$<HTMLSelectElement>('#loglevel').onchange = (e: Event) => { logLevel = (e.target as HTMLSelectElement).value; drawLog(); };
$<HTMLInputElement>('#logq').oninput = (e: Event) => { logFilter = (e.target as HTMLInputElement).value.toLowerCase(); drawLog(); };
$<HTMLButtonElement>('#logcopy').onclick = () => copyText(visibleLog().map(l =>
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $<HTMLButtonElement>('#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 = `<p class="empty">Lost contact with the daemon: ${esc(why(e))}</p>`;
}
}
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 = $<HTMLInputElement>('#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 `<div class="l"><time>${t}</time>` +
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop = box.scrollHeight;
}
showSection();