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 88f4e4765b
commit c6dcb7f34e
3 changed files with 134 additions and 97 deletions

View File

@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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).
- An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360). - An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360).
### Added ### Added

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);