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:
@@ -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).
|
||||
- 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
|
||||
- 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).
|
||||
|
||||
47
web/app.css
47
web/app.css
@@ -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
|
||||
script marks an admin's from the last visit before the first frame. */
|
||||
html:not([data-admin]) #topbar a#admin{display:none}
|
||||
body.adminpage #admin{max-width:780px;margin:0 auto}
|
||||
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}
|
||||
/* The directory's Servers and Blocked follow its Save and each other's rows (#206). */
|
||||
body.adminpage #dadmin h2{margin-top:32px}
|
||||
/* Sections laid out as a feed's settings are (#290, #414): .fsec's name beside its controls,
|
||||
above them when the column is narrow, which the container query on #admin decides. */
|
||||
body.adminpage #admin{max-width:780px;margin:0 auto;container-type:inline-size}
|
||||
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}
|
||||
.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}
|
||||
|
||||
/* ---------- items ---------- */
|
||||
|
||||
173
web/src/admin.ts
173
web/src/admin.ts
@@ -19,53 +19,53 @@ function 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 ---------------- */
|
||||
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>
|
||||
<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">
|
||||
<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 class="check"><input type="checkbox" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>
|
||||
Signals for everyone, on this server's TypeSafe key</label>
|
||||
<span class="hint">${g.typesafe_server_key
|
||||
? '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.'
|
||||
: '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>`;
|
||||
<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({
|
||||
@@ -79,6 +79,7 @@ async function drawServer(){
|
||||
// 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); }
|
||||
};
|
||||
}
|
||||
@@ -92,20 +93,20 @@ 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.
|
||||
<p class="cap">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>
|
||||
<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="field"><label for="dtoken">Access token</label>
|
||||
<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>
|
||||
<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>
|
||||
<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{
|
||||
@@ -153,21 +154,18 @@ async function drawDirectoryAdmin(){
|
||||
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>
|
||||
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="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>
|
||||
: '<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="hint">Nothing is blocked.</p>'}`;
|
||||
: '<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;
|
||||
@@ -192,22 +190,20 @@ 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'} · ${
|
||||
${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>
|
||||
${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>`;
|
||||
${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);
|
||||
@@ -245,32 +241,35 @@ const LOG_TABS: Record<string, ((t: string) => boolean)|null> = {
|
||||
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 = `<h2>Log</h2>
|
||||
<div class="logbar">
|
||||
$('#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' : ''}">${
|
||||
`<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>
|
||||
<select id="loglevel" style="width:auto">
|
||||
</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…">','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>
|
||||
<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>`;
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user