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
This commit is contained in:
2026-11-07 17:07:19 +00:00
parent 2c4d03668f
commit 6f1bc850ff
3 changed files with 137 additions and 97 deletions

View File

@@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389). - iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
- An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388). - An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388).
### Changed
- The admin page is laid out as a feed's settings are: each tab in named parts, numbers with their unit beside them, switches for on and off, Server's Save always in reach, and on a phone the tabs in a row of their own so all four fit (#414).
### Fixed ### Fixed
- Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383). - Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383).
- On the Unread tab, a short list read through no longer asks the server for more that is not there, or shows a count of 0 items beside the items still on screen (#385). - On the Unread tab, a short list read through no longer asks the server for more that is not there, or shows a count of 0 items beside the items still on screen (#385).

View File

@@ -962,12 +962,49 @@ body.adminpage #topbar > a.btn{text-decoration:none}
/* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head /* The link to Admin, for admins only: the page is the same for everyone now (#203), and the head
script marks an admin's from the last visit before the first frame. */ script marks an admin's from the last visit before the first frame. */
html:not([data-admin]) #topbar a#admin{display:none} html:not([data-admin]) #topbar a#admin{display:none}
body.adminpage #admin{max-width:780px;margin:0 auto} /* Sections laid out as a feed's settings are (#290, #414): .fsec's name beside its controls,
body.adminpage #admin h2{font-size:18px;margin:0 0 12px} above them when the column is narrow, which the container query on #admin decides. */
/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */ body.adminpage #admin{max-width:780px;margin:0 auto;container-type:inline-size}
body.adminpage #dadmin h2{margin-top:32px} body.adminpage #admin h2{font-size:22px;margin:0 0 6px;letter-spacing:-.01em}
body.adminpage #admin h2+.cap{margin-bottom:14px}
body.adminpage #admin .fsec:last-of-type{border-bottom:1px solid var(--line)}
body.adminpage #log{max-width:none} body.adminpage #log{max-width:none}
.urow{padding:9px 0;border-bottom:1px solid var(--line)} /* A setting's caption belongs to the field above it, not halfway to the next. */
#admin .fsecin>.ffield:has(+.cap){margin-bottom:0}
#admin .fsecin>.cap{margin-bottom:6px}
#admin .unit{align-self:center;color:var(--dim);font-size:14px}
#admin .apath{font-size:12.5px;color:var(--dim);overflow-wrap:anywhere}
#admin .cardacts{margin-top:8px}
/* Save stays on screen at the foot of a long section, as a feed's settings keep theirs (#167). */
.afoot{position:sticky;bottom:0;display:flex;justify-content:flex-end;padding-top:12px;padding-bottom:calc(12px + var(--safe-b));margin-top:4px;background:var(--bg)}
.afoot .btn:disabled{opacity:.45;cursor:default}
/* How the directory answers, above what sets it up. */
.astatus{margin:0 0 14px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;font-size:13.5px;line-height:1.5}
.urow{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--line)}
.urow:last-child{border-bottom:0}
.urow .uwho{flex:1;min-width:0}
.urow .uwho b{display:block;overflow-wrap:anywhere}
.urow .uwho .tag{margin-left:4px;vertical-align:1px}
.urow .uwho small{display:block;color:var(--faint);font-size:12.5px}
.urow .switch{min-height:0;gap:8px;font-size:13px;font-weight:400;color:var(--dim)}
.loghead{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.loghead h2{margin:0!important}
.loghead .tabs button{white-space:nowrap}
.logbar select{width:auto}
body.adminpage #logbox{height:calc(100dvh - 230px);min-height:240px}
#log>.cap{margin-top:8px}
/* On a phone the tabs take a row of their own under the title, where all four fit. */
@media (max-width:600px){
body.adminpage #topbar{flex-wrap:wrap;row-gap:8px}
body.adminpage #atabs{order:9;flex:1 1 100%}
body.adminpage #atabs a{flex:1;text-align:center}
.loghead .tabs{flex:1 1 100%}
.loghead .tabs button{flex:1;padding:5px 4px}
.logbar select{flex:1}
body.adminpage #logbox{height:calc(100dvh - 300px)}
#logbox .l{display:grid;grid-template-columns:auto 1fr;gap:0 8px;padding:5px 0;border-bottom:1px solid var(--line)}
#logbox .l>span:last-child{grid-column:1/-1}
}
.toolbar .grow{flex:1;min-width:150px;max-width:320px} .toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- items ---------- */ /* ---------- items ---------- */

View File

@@ -19,53 +19,53 @@ function showSection(){
} }
window.addEventListener('hashchange', showSection); 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 ---------------- */ /* ---------------- server ---------------- */
async function drawServer(){ async function drawServer(){
const box = $('#server'); const box = $('#server');
const g = await api('/api/settings'); const g = await api('/api/settings');
const gs = splitEvery(g.every_mins); const gs = splitEvery(g.every_mins);
box.innerHTML = `<h2>Server</h2> box.innerHTML = `<h2>Server</h2>
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how <p class="cap">These apply to everyone. Each person's own choices, such as keywords or how many
many items a feed downloads for them, are in that feed's settings.</p> items a feed downloads for them, are in that feed's settings.</p>
<div class="field"><label>Check feeds every</label> ${asect('Feeds', `<div class="ffield"><label class="flabel" for="gnum">Check every</label>
<div class="inline"> <div class="inline"><input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}"> <select id="gunit" aria-label="Unit">${unitOptions(gs.u)}</select></div>
<select id="gunit">${unitOptions(gs.u)}</select> <p class="cap">For every feed that does not set its own. A feed that asks to be checked less
</div> often (its <b>ttl</b>) still is.</p></div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested ${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')}
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div> <p class="cap">For every feed that does not set its own, those inside an OPML subscription too.
<div class="field"><label>Newest episodes to download, per feed</label> Older ones stay listed, to download by hand. 0 downloads every episode.</p>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}"> <div class="ffield"><label class="flabel" for="gtypes">Media types to download</label>
<span class="hint">Applies to any feed that does not set its own — including every feed <input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means <p class="cap">Anything else is listed, to download by hand: blog feeds put article images in
every episode</b>, the whole back catalogue.</span></div> enclosures. Empty takes everything.</p></div>`)}
<div class="field"><label>Download these media types automatically</label> ${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')}
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video"> <p class="cap">0 has no limit. Over it, the oldest played items go first; pinned ones never do.</p>
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds ${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')}
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div> <p class="cap">0 keeps them.</p>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label> ${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')}
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}"> <p class="cap">Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.</p>
<span class="hint">Over this, the oldest played items are deleted first. Pinned <div class="ffield"><span class="flabel">Download folder</span>
items are never touched.</span></div> <code class="apath">${esc(g.download_dir)}</code></div>`)}
<div class="field"><label>Delete items older than (days, 0 = keep)</label> ${asect('Signals', `${aswitch(`<input type="checkbox" role="switch" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>`,
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div> 'On this server\'s key, for everyone')}
<div class="field"><label>Artwork kept (MB, 0 = none)</label> <p class="cap">${g.typesafe_server_key
<input type="number" id="gart" min="0" value="${g.art_cache_mb}"> ? '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.'
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX : 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</p>
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div> ${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')}
<div class="field"><label class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}> <p class="cap">Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.</p>`)}
Signals for everyone, on this server's TypeSafe key</label> <div class="afoot"><button class="btn primary" id="gsave" disabled>${ICON.check} Save</button></div>`;
<span class="hint">${g.typesafe_server_key // Save waits for a change, so it says whether there is one.
? 'On, everyone has Signals and every item read for one is a call on this server\'s key. Off, each person turns Signals on in their own Settings with a key of their own.' box.oninput = () => { $<HTMLButtonElement>('#gsave').disabled = false; };
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</span></div>
<div class="field"><label for="gsigdays">Signals look back (days)</label>
<input type="number" id="gsigdays" min="1" max="365" value="${g.signal_days}">
<span class="hint">How far back a new Signal reads. Each item read is a call to TypeSafe, so a
longer look back costs more, once for each new Signal. Editing a Signal reads again only what
it had picked out.</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 () => { $<HTMLButtonElement>('#gsave').onclick = async () => {
try{ try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({ await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
@@ -79,6 +79,7 @@ async function drawServer(){
// Left alone while the server has no key to offer, rather than switched off. // Left alone while the server has no key to offer, rather than switched off.
...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})}); ...($<HTMLInputElement>('#gsig').disabled ? {} : {signals_on_server: $<HTMLInputElement>('#gsig').checked})})});
toast('Settings saved'); toast('Settings saved');
$<HTMLButtonElement>('#gsave').disabled = true;
}catch(e){ toast(why(e), true); } }catch(e){ toast(why(e), true); }
}; };
} }
@@ -92,20 +93,20 @@ async function drawDirectorySettings(){
const box = $('#directory'); const box = $('#directory');
const g = await api('/api/settings'); const g = await api('/api/settings');
box.innerHTML = `<h2>Directory</h2> box.innerHTML = `<h2>Directory</h2>
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share. <p class="cap">The Directory shows podcasts from a directory service that iPX servers share.
Without one, people add feeds by their address.</p> Without one, people add feeds by their address.</p>
<div class="field"><label for="durl">Directory address</label> <p class="astatus" id="dstatus" role="status">Asking the directory…</p>
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div> ${asect('Connection', `<div class="ffield"><label class="flabel" for="durl">Address</label>
<div class="field"><label for="dtoken">Access token</label> <input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false" <div class="ffield"><label class="flabel" for="dtoken">Access token</label>
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}"> <div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div> placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div> ${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label> <p class="cap">Whoever runs the directory makes one for each server, and can revoke it.</p></div>
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every ${aswitch(`<input type="checkbox" role="switch" id="dshare"${g.directory_share ? ' checked' : ''}>`, 'Share this server\'s public feeds')}
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div> <p class="cap">Every half hour, the feeds people here subscribe to and how many do. Never a
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div> 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 class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
<div id="dadmin"></div>`; <div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => { const save = async (patch: Record<string, unknown>, said: string) => {
try{ try{
@@ -153,21 +154,18 @@ async function drawDirectoryAdmin(){
let a: DirAdmin; let a: DirAdmin;
try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; } try{ a = await api('/api/directory-admin'); }catch(e){ box.innerHTML = `<p class="hint">${esc(why(e))}</p>`; return; }
if(!box.isConnected) return; if(!box.isConnected) return;
box.innerHTML = `<h2>Servers</h2> box.innerHTML = asect('Servers', `<p class="cap">Each server sharing with the directory, by its token's
<p class="hint">Each server sharing with the directory, by its token's name. Dropping one removes its name. Dropping one removes its feeds and counts until it next shares; to keep it out, revoke its
feeds and counts until it next shares; to keep it out, revoke its token in the directory's config.</p> token in the directory's config.</p>
${a.instances.length ? a.instances.map(i => `<div class="inline urow" data-name="${esc(i.name)}"> ${a.instances.length ? a.instances.map(i => `<div class="urow" data-name="${esc(i.name)}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(i.name)}</b> <div class="uwho"><b>${esc(i.name)}</b><small>${plural(i.feeds, 'feed')} · ${plural(i.subscribers, 'subscriber')}</small></div>
<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('') <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>'} : '<p class="cap">No server is sharing yet.</p>'}`) +
<h2>Blocked</h2> asect('Blocked', `<p class="cap">Feeds removed from the directory. No snapshot brings them back until they are unblocked.</p>
<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="urow" data-id="${esc(b.id)}">
${a.blocked.length ? a.blocked.map(b => `<div class="inline urow" data-id="${esc(b.id)}"> <div class="uwho"><b>${esc(b.title || 'Untitled')}</b><small>Removed ${dateOf(b.at)}</small></div>
<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('') <button class="btn" data-a="unblock">Unblock</button></div>`).join('')
: '<p class="hint">Nothing is blocked.</p>'}`; : '<p class="cap">Nothing is blocked.</p>'}`);
box.onclick = async (ev: MouseEvent) => { box.onclick = async (ev: MouseEvent) => {
const b = (ev.target as Element).closest<HTMLElement>('[data-a]'); const b = (ev.target as Element).closest<HTMLElement>('[data-a]');
if(!b) return; if(!b) return;
@@ -192,22 +190,20 @@ async function drawAccounts(){
const box = $('#accounts'); const box = $('#accounts');
const users: Account[] = await api('/api/users') || []; const users: Account[] = await api('/api/users') || [];
box.innerHTML = `<h2>Accounts</h2> box.innerHTML = `<h2>Accounts</h2>
${users.map(u => `<div class="inline urow" data-id="${u.id}"> ${asect('People', users.map(u => `<div class="urow" data-id="${u.id}">
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b> <div class="uwho"><b>${esc(u.name)}${u.password ? '' : ' <span class="tag" title="No password: signs in through the proxy">Proxy</span>'}</b>
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${ <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> 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>'} ${aswitch(`<input type="checkbox" role="switch" data-a="admin" ${u.admin ? 'checked' : ''}>`, 'Admin')}
<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(''))}
<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>
<div class="field" style="margin-top:20px"><label>Add someone</label> <input type="text" id="uname" autocomplete="off" spellcheck="false"></div>
<div class="inline"> <div class="ffield"><label class="flabel" for="upass">Password</label>
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false"> <input type="password" id="upass" autocomplete="new-password">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password"> <p class="cap">At least 8 characters. Empty for someone who signs in through the proxy.</p></div>
</div> ${aswitch('<input type="checkbox" role="switch" id="uadmin">', 'Admin')}
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label> <p class="cap">New people start with no feeds.</p>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in <div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`)}`;
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) => { const change = async (u: Account, opts: RequestInit) => {
try{ try{
await api(`/api/users/${u.id}`, opts); await api(`/api/users/${u.id}`, opts);
@@ -245,32 +241,35 @@ const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
scan: t => t === 'ipx::scan', scan: t => t === 'ipx::scan',
web: t => t === 'ipx::http', 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}; const LEVELS: Record<string,number> = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
function drawLogView(){ function drawLogView(){
logSeq = 0; logLines = []; logSeq = 0; logLines = [];
$('#log').innerHTML = `<h2>Log</h2> $('#log').innerHTML = `<div class="loghead"><h2>Log</h2>
<div class="logbar">
<div class="tabs" id="logtabs"> <div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t => ${Object.keys(LOG_TABS).map(t =>
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${ `<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('')} {all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
</div> </div></div>
<select id="loglevel" style="width:auto"> <div class="logbar">
<select id="loglevel" aria-label="Level">
<option value="">All levels</option> <option value="">All levels</option>
<option value="INFO">Info and above</option> <option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option> <option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option> <option value="ERROR">Errors only</option>
</select> </select>
${searchBox('<input type="search" id="logq" placeholder="Filter…">','grow')} ${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> <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> <button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
</div> </div>
<div id="logbox"><p class="empty">Loading…</p></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 <p class="cap">Debug lines are kept here even when the container's log leaves them out;
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests. <b>IPX_UI_LOG</b> changes what is kept.</p>`;
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 = () => { for(const b of $$('#logtabs button')) b.onclick = () => {
logTab = b.dataset.t!; logTab = b.dataset.t!;
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab); for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);