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
326 lines
19 KiB
TypeScript
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();
|