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:
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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).
|
||||
|
||||
### Added
|
||||
|
||||
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 ---------- */
|
||||
|
||||
183
web/src/admin.ts
183
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>
|
||||
<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">These apply to everyone. Each person's own choices, such as keywords or how many
|
||||
items a feed downloads for them, are in that feed's settings.</p>
|
||||
${asect('Feeds', `<div class="ffield"><label class="flabel" for="gnum">Check every</label>
|
||||
<div class="inline"><input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||
<select id="gunit" aria-label="Unit">${unitOptions(gs.u)}</select></div>
|
||||
<p class="cap">For every feed that does not set its own. A feed that asks to be checked less
|
||||
often (its <b>ttl</b>) still is.</p></div>
|
||||
${anum('gmax', 'Download the newest', g.max_new_per_check, 'per feed', ' min="0" max="999"')}
|
||||
<p class="cap">For every feed that does not set its own, those inside an OPML subscription too.
|
||||
Older ones stay listed, to download by hand. 0 downloads every episode.</p>
|
||||
<div class="ffield"><label class="flabel" for="gtypes">Media types to download</label>
|
||||
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||
<p class="cap">Anything else is listed, to download by hand: blog feeds put article images in
|
||||
enclosures. Empty takes everything.</p></div>`)}
|
||||
${asect('Storage', `${anum('gquota', 'Disk quota', g.max_total_gb, 'GB', ' min="0" step="0.5"')}
|
||||
<p class="cap">0 has no limit. Over it, the oldest played items go first; pinned ones never do.</p>
|
||||
${anum('gage', 'Delete items after', g.max_age_days, 'days', ' min="0"')}
|
||||
<p class="cap">0 keeps them.</p>
|
||||
${anum('gart', 'Artwork kept', g.art_cache_mb, 'MB', ' min="0"')}
|
||||
<p class="cap">Artwork is kept once shown, so it loads from iPX rather than each publisher. 0 keeps none.</p>
|
||||
<div class="ffield"><span class="flabel">Download folder</span>
|
||||
<code class="apath">${esc(g.download_dir)}</code></div>`)}
|
||||
${asect('Signals', `${aswitch(`<input type="checkbox" role="switch" id="gsig"${g.signals_on_server ? ' checked' : ''}${g.typesafe_server_key ? '' : ' disabled'}>`,
|
||||
'On this server\'s key, for everyone')}
|
||||
<p class="cap">${g.typesafe_server_key
|
||||
? 'On, every item read for a Signal is a call on this server\'s TypeSafe key. Off, each person turns Signals on in their own Settings, with their own key.'
|
||||
: 'This server has no TypeSafe key (<code>TYPESAFE_KEY</code>), so each person uses their own.'}</p>
|
||||
${anum('gsigdays', 'A new Signal looks back', g.signal_days, 'days', ' min="1" max="365"')}
|
||||
<p class="cap">Each item read is a call to TypeSafe, so a longer look back costs more, once for each new Signal.</p>`)}
|
||||
<div class="afoot"><button class="btn primary" id="gsave" disabled>${ICON.check} Save</button></div>`;
|
||||
// Save waits for a change, so it says whether there is one.
|
||||
box.oninput = () => { $<HTMLButtonElement>('#gsave').disabled = false; };
|
||||
$<HTMLButtonElement>('#gsave').onclick = async () => {
|
||||
try{
|
||||
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||
@@ -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>
|
||||
<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>
|
||||
<p class="astatus" id="dstatus" role="status">Asking the directory…</p>
|
||||
${asect('Connection', `<div class="ffield"><label class="flabel" for="durl">Address</label>
|
||||
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://directory.example.net" spellcheck="false"></div>
|
||||
<div class="ffield"><label class="flabel" for="dtoken">Access token</label>
|
||||
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
|
||||
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
|
||||
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
|
||||
<p class="cap">Whoever runs the directory makes one for each server, and can revoke it.</p></div>
|
||||
${aswitch(`<input type="checkbox" role="switch" id="dshare"${g.directory_share ? ' checked' : ''}>`, 'Share this server\'s public feeds')}
|
||||
<p class="cap">Every half hour, the feeds people here subscribe to and how many do. Never a
|
||||
private feed: one marked private, one with a login, or one whose address carries a key.</p>
|
||||
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
|
||||
<div id="dadmin"></div>`;
|
||||
const save = async (patch: Record<string, unknown>, said: string) => {
|
||||
try{
|
||||
@@ -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