$<T> returns the element type it is given and assumes the element is there: 122 lookups take their type from the tag their id is on in the markup. A handler reads its target through a cast, since a typed element will not take a handler whose event names a narrower target. strictNullChecks found no bug the page had; its 94 places were values the code already handles in ways the type-check could not follow (data- attributes the page writes, a map entry set the line before, Share drawn only for an item with a link), each now said so. isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it is also false for a file that is not audio or video. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
18 KiB
TypeScript
314 lines
18 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);
|
|
|
|
/* ---------------- 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="hint">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>
|
|
<div class="field"><label>Check feeds every</label>
|
|
<div class="inline">
|
|
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
|
<select id="gunit">${unitOptions(gs.u)}</select>
|
|
</div>
|
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
|
<div class="field"><label>Newest episodes to download, per feed</label>
|
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
|
<span class="hint">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. <b>0 means
|
|
every episode</b>, the whole back catalogue.</span></div>
|
|
<div class="field"><label>Download these media types automatically</label>
|
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
|
<span class="hint">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.</span></div>
|
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
|
<span class="hint">Over this, the oldest played items are deleted first. Pinned
|
|
items are never touched.</span></div>
|
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
|
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
|
|
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
|
<span class="hint">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.</span></div>
|
|
<div class="field"><label>Download folder</label>
|
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
|
$<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)})});
|
|
toast('Settings saved');
|
|
}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="hint">The Directory shows podcasts from a directory service that iPX servers share.
|
|
Without one, people add feeds by their address.</p>
|
|
<div class="field"><label for="durl">Directory address</label>
|
|
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
|
|
<div class="field"><label 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>
|
|
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
|
|
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
|
|
<span class="hint">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.</span></div>
|
|
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
|
|
<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 = `<h2>Servers</h2>
|
|
<p class="hint">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="inline urow" data-name="${esc(i.name)}">
|
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b>
|
|
<small style="display:block;color:var(--faint)">${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="hint">No server is sharing yet.</p>'}
|
|
<h2>Blocked</h2>
|
|
<p class="hint">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
|
|
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}">
|
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(b.title || 'Untitled')}</b>
|
|
<small style="display:block;color:var(--faint)">Removed ${dateOf(b.at)}</small></div>
|
|
<button class="btn" data-a="unblock">Unblock</button></div>`).join('')
|
|
: '<p class="hint">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>
|
|
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
|
<small style="display:block;color:var(--faint)">${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>
|
|
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
|
|
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
|
|
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
|
|
<div class="field" style="margin-top:20px"><label>Add someone</label>
|
|
<div class="inline">
|
|
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
|
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
|
</div>
|
|
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
|
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
|
through the proxy. New people start with no feeds.</span></div>
|
|
<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',
|
|
};
|
|
const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
|
|
|
function drawLogView(){
|
|
logSeq = 0; logLines = [];
|
|
$('#log').innerHTML = `<h2>Log</h2>
|
|
<div class="logbar">
|
|
<div class="tabs" id="logtabs">
|
|
${Object.keys(LOG_TABS).map(t =>
|
|
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
|
|
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
|
</div>
|
|
<select id="loglevel" style="width:auto">
|
|
<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>
|
|
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
|
<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>
|
|
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
|
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
|
what it captures.</span>`;
|
|
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();
|