templates/administration.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Administration — Lectio</title>
<script>
// Load the user's theme so CSS variables (--accent, --surface, …) are defined;
// without it the page is unstyled and buttons render transparent.
window.__lectioTheme = (window.localStorage.getItem('lectio-theme') || 'dark');
document.write(`<link rel="stylesheet" href="/static/themes/${window.__lectioTheme}.css?v={{ static_asset_version }}" />`);
</script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1" />
<link rel="stylesheet" href="/static/style.css?v={{ static_asset_version }}" />
<style>
body.account-page { height: auto; min-height: 100vh; overflow-y: auto; }
.account-wrap { max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
.account-wrap header { display: flex; justify-content: space-between; align-items: baseline; }
.account-section { margin: 1.5rem 0; padding: 1rem 1.25rem; border: 1px solid var(--line, #ccc); border-radius: 8px; }
.account-section h2 { margin-top: 0; font-size: 1.05rem; }
.account-section h3 { font-size: .95rem; margin: 1rem 0 .35rem; }
.account-section form { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .5rem 0; }
.account-section label.fld { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; }
.account-section input[type="text"], .account-section input[type="password"],
.account-section input[type="number"], .account-section input[type="email"],
.account-section select { padding: .4rem .5rem; }
.account-msg { padding: .5rem .75rem; background: rgba(40,160,80,.15); border-radius: 6px; }
.account-users { width: 100%; border-collapse: collapse; margin: .5rem 0; }
.account-users th, .account-users td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid rgba(127,127,127,.25); }
.account-hint { font-size: .82rem; opacity: .75; margin: .25rem 0; }
/* Group descriptions sit between the heading and the field grid — give the
fields a bit of air below the text. */
h3 + .account-hint { margin-bottom: .65rem; }
.account-users button.danger { color: var(--danger, #c0392b); border-color: var(--danger, #c0392b); }
.account-users td > div form { margin: 0; }
/* tabs */
.adm-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line, #ccc); margin: 1rem 0 0; }
.adm-tab { padding: .45rem 1rem; cursor: pointer; border: 1px solid transparent;
border-bottom: none; border-radius: 6px 6px 0 0; font-size: .9rem;
background: none; color: inherit; }
.adm-tab.active { border-color: var(--line, #ccc); background: var(--surface, #fff);
margin-bottom: -1px; font-weight: 600; }
.adm-panel { display: none; }
.adm-panel.active { display: block; }
.tuning-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .5rem; }
/* Inputs pin to the cell bottom so a label that wraps to two lines can't
push its field out of alignment with the rest of the row. */
.tuning-grid .fld input, .tuning-grid .fld select { margin-top: auto; }
/* Credential pairs (ID + secret) are longer than tuning numbers — wider columns. */
.keys-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.adm-icon-btn { background: none; border: none; padding: .1rem; cursor: pointer; color: inherit;
opacity: .65; vertical-align: middle; line-height: 1; }
.adm-icon-btn:hover { opacity: 1; }
.adm-icon-btn .material-symbols-rounded { font-size: 1.1rem; }
.adm-icon-btn.danger { color: var(--danger, #c0392b); }
.adm-user-actions { display: flex; gap: .15rem; align-items: center; }
.adm-logs-controls { display: flex; gap: .75rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: .5rem; }
.adm-log-presets { display: flex; gap: .25rem; align-items: center; }
.adm-log-preset { padding: .3rem .55rem; font-size: .8rem; cursor: pointer; }
.adm-log-wrap-lbl { display: flex; align-items: center; gap: .3rem; font-size: .85rem; white-space: nowrap; }
.adm-log-output { max-height: 62vh; overflow: auto; background: rgba(127,127,127,.08);
border: 1px solid var(--line, #ccc); border-radius: 6px; padding: .6rem .75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem;
line-height: 1.4; white-space: pre; margin: 0; }
.adm-log-output.wrap { white-space: pre-wrap; word-break: break-word; }
/* Per-level colorization. Continuation lines (tracebacks) inherit their
record's class so a stack trace stays red under its ERROR. */
.adm-log-output .lvl-debug { color: var(--muted, #8a8a8a); }
.adm-log-output .lvl-warning { color: #c9931f; }
.adm-log-output .lvl-error,
.adm-log-output .lvl-critical { color: var(--danger, #d2544a); }
.adm-log-output .log-ts { opacity: .55; }
.adm-log-output .log-tag { color: var(--accent, #3a7abf); font-weight: 600; }
/* Keep the tag readable when the whole line is an error/warning color. */
.adm-log-output .lvl-error .log-tag,
.adm-log-output .lvl-critical .log-tag { color: inherit; text-decoration: underline; }
</style>
</head>
<body class="account-page">
<div class="account-wrap">
<header>
<h1>Administration</h1>
<a href="/">← Back to Lectio</a>
</header>
{% if message %}<p class="account-msg" role="status">{{ message }}</p>{% endif %}
{% if error %}<p class="login-error" role="alert">{{ error }}</p>{% endif %}
<p id="admin-toast" class="account-msg" role="status" hidden></p>
<div class="adm-tabs">
<button class="adm-tab active" data-panel="panel-settings">Settings</button>
<button class="adm-tab" data-panel="panel-keys">Shared keys</button>
<button class="adm-tab" data-panel="panel-users">Users</button>
<button class="adm-tab" data-panel="panel-logs">Logs</button>
</div>
<!-- ── Settings (instance behavior) ──────────────────────── -->
<div id="panel-settings" class="adm-panel active">
<section class="account-section">
<h2>Instance settings</h2>
<p class="account-hint">Shared across the whole instance. Changes apply immediately after Save.</p>
<h3>Maintenance & cleanup</h3>
<p class="account-hint">Runs nightly at the set hour (server local time): prunes feed fetch history, applies per-folder retention, sweeps tombstones, and evicts the image cache.{% if maintenance_last %} Last ran: {{ maintenance_last }}.{% endif %}</p>
<div class="tuning-grid">
<label class="fld">Maintenance hour (0–23)
<input type="number" id="adm-maint-hour" min="0" max="23" value="{{ maintenance_hour }}" placeholder="3" />
</label>
<label class="fld" title="How long feed-refresh attempts (success/error, per feed) are kept for diagnosing a feed that's gone quiet. A fixed 5000-row-per-feed ceiling backstops this against a runaway retry bug; you shouldn't need to think about it.">Feed history max age (days)
<input type="number" id="adm-fetch-history-max-age" min="1" value="{{ fetch_history_max_age_days }}" placeholder="30" />
</label>
<label class="fld" title="New posts dated older than this are marked read when fetched, for every account. Accounts, folders and feeds can only shorten it. 0 = off.">Auto-read old posts (days, 0 = off)
<input type="number" id="adm-auto-read-days" min="0" value="{{ auto_read_days_instance }}" placeholder="0" />
</label>
<label class="fld" title="Deleted-post tombstones stop refreshes from resurrecting purged entries. A tombstone is swept once it is this old AND its entry has left the publisher's last-known feed window (quiet feeds keep serving old posts, so age alone is never trusted). 0 keeps them forever.">Tomb sweeping (days, 0 = never)
<input type="number" id="adm-tombstone-sweep" min="0" value="{{ tombstone_sweep_days }}" placeholder="90" />
</label>
</div>
<div style="margin:.25rem 0 .75rem">
<button type="button" id="adm-run-maint">Run maintenance now</button>
</div>
<h3>Image cache</h3>
<p class="account-hint">The <code>/api/img</code> proxy caches fetched images. Entries not viewed within the retention window are evicted nightly; larger images are downscaled to the max dimension before storing.</p>
<div class="tuning-grid">
<label class="fld">Retention (days, 0 = keep forever)
<input type="number" id="adm-img-cache-days" min="0" value="{{ img_cache_days }}" placeholder="90" />
</label>
<label class="fld">Max dimension (px, 0 = originals)
<input type="number" id="adm-img-cache-max-dim" min="0" value="{{ img_cache_max_dim }}" placeholder="3840" />
</label>
{# Separate from max dimension because they answer different questions:
a 4K PNG sits exactly at the dimension cap and still weighs ~12 MB,
and it is the bytes that make it slow to load. #}
<label class="fld">Size budget (bytes, 0 = as delivered)
<input type="number" id="adm-img-target-bytes" min="0" value="{{ img_target_bytes }}" placeholder="1500000" />
</label>
</div>
<p class="account-hint">An image over the size budget is re-encoded to WebP — losslessly for logos, diagrams and line art, where lossy compression would smear the edges, and lossy otherwise. The dimension cap alone does not bound size.</p>
<h3>Outbound proxy</h3>
<p class="account-hint">Routes feed fetches through a SOCKS5/HTTP proxy (e.g. a gluetun VPN container on the same Docker network) — useful against ordinary geoblocks or per-IP rate limits, not against sites running real bot detection. This is the instance default; a user can override it for themselves in Settings → Account. Off everywhere until set.</p>
<div class="tuning-grid">
<label class="fld">Proxy URL
<input type="text" id="adm-proxy-url" value="{{ proxy_url }}" placeholder="socks5h://gluetun:1080" autocomplete="off" />
</label>
<label class="fld">Default mode
<select id="adm-proxy-mode">
<option value="off" {{ 'selected' if proxy_mode == 'off' }}>Off</option>
<option value="as_needed" {{ 'selected' if proxy_mode == 'as_needed' }}>As needed</option>
<option value="always" {{ 'selected' if proxy_mode == 'always' }}>Always</option>
</select>
</label>
</div>
<p class="account-hint">FlareSolverr (real headless Chrome, purpose-built to solve Cloudflare/DDoS-Guard style challenges) — one rung past the proxy above, before the last-resort backend below. Only reached in "As needed" mode for a feed still failing with an actual challenge page (not a plain block or rate limit), and always stacked with the proxy above when one is configured. Leave blank to opt out — no token needed for an internal-network container.</p>
<div class="tuning-grid">
<label class="fld">FlareSolverr URL
<input type="text" id="adm-flaresolverr-url" value="{{ flaresolverr_url }}" placeholder="http://flaresolverr:8191/v1" autocomplete="off" />
</label>
</div>
<p class="account-hint">Last-resort backend (e.g. a Tailscale exit node reaching a real home IP) — a different trust tier from the proxy above: real exposure cost, not a disposable datacenter address. Only reached in "As needed" mode, the last escalation rung (browser identity, then proxy, then FlareSolverr, then this), never in "Always". Leave blank to opt out entirely — an unconfigured URL is the gate. Also honors the "As needed" mode override in Settings → Account.</p>
<div class="tuning-grid">
<label class="fld">Last-resort proxy URL
<input type="text" id="adm-tailscale-url" value="{{ tailscale_url }}" placeholder="socks5h://tailscale:1080" autocomplete="off" />
</label>
</div>
<h3>Security</h3>
<p class="account-hint">Repeated failed logins lock the account out for the window.</p>
<div class="tuning-grid">
<label class="fld">Login max failures
<input type="number" id="adm-login-max-failures" min="1" value="{{ login_max_failures }}" placeholder="5" />
</label>
<label class="fld">Lockout window (seconds)
<input type="number" id="adm-login-window-seconds" min="60" value="{{ login_window_seconds }}" placeholder="300" />
</label>
</div>
<h3>New users</h3>
<p class="account-hint">Seeds the auto-refresh setting for newly created users.</p>
<div class="tuning-grid">
<label class="fld">Default auto-refresh (min, 0 = off)
<input type="number" id="adm-instance-auto-refresh" min="0" value="{{ instance_auto_refresh }}" placeholder="60" />
</label>
</div>
<div style="margin-top:.75rem">
<button type="button" class="adm-save">Save configuration</button>
</div>
</section>
</div>
<!-- ── Shared keys (instance-wide credentials) ───────────── -->
<div id="panel-keys" class="adm-panel">
<section class="account-section">
<h2>Shared keys</h2>
<p class="account-hint">Instance-wide credentials. The email key sends on behalf of the whole instance; the OAuth apps are a shared fallback for users who haven't set their own (a user's own credentials always take priority).</p>
<h3>Email (Resend)</h3>
<p class="account-hint">One sending account for the instance (only a verified domain can send).</p>
<div class="tuning-grid keys-grid">
<label class="fld">API key
<input type="password" id="adm-resend-key" autocomplete="new-password"
placeholder="{{ resend_key_masked if resend_key_set else 're_…' }}" />
</label>
<label class="fld">From address
<input type="email" id="adm-email-from" value="{{ email_from }}" placeholder="lectio@yourdomain.com" />
</label>
</div>
<h3>YouTube OAuth</h3>
<p class="account-hint">Register at the <a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a> with <code>{{ public_url }}/youtube/oauth/callback</code> as an authorized redirect URI.</p>
<div class="tuning-grid keys-grid">
<label class="fld">Client ID
<input type="text" id="adm-yt-oauth-client-id" value="{{ shared_yt_oauth_client_id }}" placeholder="166660….apps.googleusercontent.com" autocomplete="off" />
</label>
<label class="fld">Client secret
<input type="password" id="adm-yt-oauth-client-secret" autocomplete="new-password"
placeholder="{{ shared_yt_oauth_client_secret_masked if shared_yt_oauth_client_secret_set else 'GOCSPX-…' }}" />
</label>
</div>
<h3>Pinterest OAuth</h3>
<p class="account-hint">Register at <a href="https://developers.pinterest.com/apps/" target="_blank" rel="noopener">Pinterest Developers</a> with <code>{{ public_url }}/integrations/pinterest/oauth/callback</code> as a redirect URI.</p>
<div class="tuning-grid keys-grid">
<label class="fld">Client ID
<input type="text" id="adm-pinterest-oauth-client-id" value="{{ shared_pinterest_oauth_client_id }}" placeholder="your app ID" autocomplete="off" />
</label>
<label class="fld">Client secret
<input type="password" id="adm-pinterest-oauth-client-secret" autocomplete="new-password"
placeholder="{{ shared_pinterest_oauth_client_secret_masked if shared_pinterest_oauth_client_secret_set else 'your client secret' }}" />
</label>
</div>
<h3>Reddit OAuth</h3>
<p class="account-hint">Register a <em>web app</em> at <a href="https://www.reddit.com/prefs/apps" target="_blank" rel="noopener">reddit.com/prefs/apps</a> with <code>{{ public_url }}/integrations/reddit/oauth/callback</code> as the redirect URI.</p>
<div class="tuning-grid keys-grid">
<label class="fld">Client ID
<input type="text" id="adm-reddit-client-id" value="{{ shared_reddit_client_id }}" placeholder="your app client ID" autocomplete="off" />
</label>
<label class="fld">Client secret
<input type="password" id="adm-reddit-client-secret" autocomplete="new-password"
placeholder="{{ shared_reddit_client_secret_masked if shared_reddit_client_secret_set else 'your client secret' }}" />
</label>
</div>
<div style="margin-top:.75rem">
<button type="button" class="adm-save">Save configuration</button>
</div>
</section>
</div>
<!-- ── Users ─────────────────────────────────────────────── -->
<div id="panel-users" class="adm-panel">
<section class="account-section">
<h2>Users</h2>
<table class="account-users">
<thead><tr><th>User</th><th>Admin</th><th>Last active</th><th>Feeds</th><th>DB size</th><th>Actions</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}{% if u.disabled %} <em>(disabled)</em>{% endif %}
<button type="button" class="adm-icon-btn" data-user-act="rename" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Rename (login name only — data and tokens unaffected)" aria-label="Rename {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">edit</span></button>
</td>
<td>{{ 'yes' if u.is_admin else '' }}</td>
<td>{{ u.last_active }}</td>
<td>{{ u.feed_count }}</td>
<td>{{ u.db_human }}</td>
<td>
<div class="adm-user-actions">
<button type="button" class="adm-icon-btn" data-user-act="vacuum" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Vacuum DBs (reclaim space after purges; runs in background)" aria-label="Vacuum {{ u.username }}'s databases"><span class="material-symbols-rounded" aria-hidden="true">cleaning_services</span></button>
{% if u.user_id != user_id %}
<button type="button" class="adm-icon-btn" data-user-act="resetpw" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Reset password" aria-label="Reset {{ u.username }}'s password"><span class="material-symbols-rounded" aria-hidden="true">key</span></button>
<button type="button" class="adm-icon-btn" data-user-act="disable" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" data-disabled="{{ 1 if u.disabled else 0 }}" title="{{ 'Enable' if u.disabled else 'Disable' }}" aria-label="{{ 'Enable' if u.disabled else 'Disable' }} {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">{{ 'person' if u.disabled else 'person_off' }}</span></button>
<button type="button" class="adm-icon-btn danger" data-user-act="delete" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Delete user and ALL their data" aria-label="Delete {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">delete</span></button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>Create user</h3>
<form method="post" action="/admin/users/create">
{{ csrf_input(request) | safe }}
<input type="text" name="username" placeholder="username" minlength="4" maxlength="10" required />
<input type="password" name="password" placeholder="password" autocomplete="new-password" minlength="6" maxlength="36" required />
<label><input type="checkbox" name="is_admin" value="1" /> admin</label>
<button type="submit">Create user</button>
</form>
<p class="account-hint">Password changes and API tokens for your own account live in Lectio's Settings → Account.</p>
</section>
</div>
<!-- ── Logs ──────────────────────────────────────────────── -->
<div id="panel-logs" class="adm-panel">
<section class="account-section">
<h2>Logs</h2>
<div class="adm-logs-controls">
<label class="fld">Since
<input type="datetime-local" id="adm-log-since" step="60" />
</label>
<label class="fld">Until
<input type="datetime-local" id="adm-log-until" step="60" title="Leave blank for now" />
</label>
<label class="fld">Level
<select id="adm-log-level">
<option value="all" selected>All</option>
<option value="info">Info+</option>
<option value="warning">Warning+</option>
<option value="error">Error only</option>
</select>
</label>
<label class="fld">Category
<select id="adm-log-category">
<option value="all" selected>All</option>
<option value="automations">Automations</option>
<option value="maintenance">Maintenance</option>
<option value="refresh">Refresh & fetch</option>
<option value="youtube">YouTube</option>
<option value="integrations">Integrations</option>
<option value="images">Images</option>
<option value="other">Other / untagged</option>
</select>
</label>
<div class="adm-log-presets">
<button type="button" class="adm-log-preset" data-mins="15">15m</button>
<button type="button" class="adm-log-preset" data-mins="60">1h</button>
<button type="button" class="adm-log-preset" data-mins="360">6h</button>
<button type="button" class="adm-log-preset" data-mins="1440">1d</button>
</div>
<label class="adm-log-wrap-lbl"><input type="checkbox" id="adm-log-wrap" /> Wrap</label>
<button type="button" id="adm-log-refresh">↻ Refresh</button>
</div>
<p id="adm-log-status" class="account-hint"></p>
<pre id="adm-log-output" class="adm-log-output" aria-live="polite"></pre>
</section>
</div>
</div>
<script>
(function () {
const CSRF = "{{ csrf_token_for(request) }}";
const $ = id => document.getElementById(id);
const toast = (msg) => { const t = $('admin-toast'); t.textContent = msg; t.hidden = false; };
// Tab switching
document.querySelectorAll('.adm-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.adm-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.adm-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.panel).classList.add('active');
});
});
async function post(url, payload) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
credentials: 'same-origin',
body: JSON.stringify(payload || {}),
});
if (!r.ok) throw new Error('failed');
}
async function saveConfig() {
const payload = {
email_from: $('adm-email-from')?.value.trim() || '',
maintenance_hour: $('adm-maint-hour')?.value.trim() || '',
img_cache_days: $('adm-img-cache-days')?.value.trim() || '',
img_cache_max_dim: $('adm-img-cache-max-dim')?.value.trim() || '',
img_target_bytes: $('adm-img-target-bytes')?.value.trim() || '',
proxy_url: $('adm-proxy-url')?.value.trim() || '',
proxy_mode: $('adm-proxy-mode')?.value || '',
tailscale_url: $('adm-tailscale-url')?.value.trim() || '',
flaresolverr_url: $('adm-flaresolverr-url')?.value.trim() || '',
shared_yt_oauth_client_id: $('adm-yt-oauth-client-id')?.value.trim() || '',
shared_pinterest_oauth_client_id: $('adm-pinterest-oauth-client-id')?.value.trim() || '',
fetch_history_max_age_days: $('adm-fetch-history-max-age')?.value.trim() || '',
tombstone_sweep_days: $('adm-tombstone-sweep')?.value.trim() || '',
auto_read_days_instance: $('adm-auto-read-days')?.value.trim() || '',
login_max_failures: $('adm-login-max-failures')?.value.trim() || '',
login_window_seconds: $('adm-login-window-seconds')?.value.trim() || '',
default_auto_refresh_minutes: $('adm-instance-auto-refresh')?.value.trim() || '',
};
const rk = $('adm-resend-key')?.value.trim();
if (rk) payload.resend_api_key = rk;
const ytSecret = $('adm-yt-oauth-client-secret')?.value.trim();
if (ytSecret) payload.shared_yt_oauth_client_secret = ytSecret;
const piSecret = $('adm-pinterest-oauth-client-secret')?.value.trim();
if (piSecret) payload.shared_pinterest_oauth_client_secret = piSecret;
const rdId = $('adm-reddit-client-id')?.value.trim();
if (rdId !== undefined) payload.shared_reddit_client_id = rdId;
const rdSecret = $('adm-reddit-client-secret')?.value.trim();
if (rdSecret) payload.shared_reddit_client_secret = rdSecret;
try { await post('/settings/all', payload); toast('Configuration saved.'); }
catch { toast('Failed to save.'); }
}
document.querySelectorAll('.adm-save').forEach(btn => btn.addEventListener('click', saveConfig));
$('adm-run-maint')?.addEventListener('click', async () => {
try { await post('/settings/maintenance/run-now'); toast('Maintenance started.'); }
catch { toast('Failed to start maintenance.'); }
});
// ── Users table glyph actions ─────────────────────────────
async function postForm(url, fields) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': CSRF },
credentials: 'same-origin',
body: new URLSearchParams(fields).toString(),
});
if (!r.ok) throw new Error('failed');
return r;
}
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('[data-user-act]');
if (!btn) return;
const act = btn.dataset.userAct;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
try {
if (act === 'rename') {
const name = prompt(`New login name for ${username} (data and tokens are unaffected):`, username);
if (!name || name === username) return;
await postForm('/admin/users/rename', { user_id: userId, new_username: name.trim() });
location.reload();
} else if (act === 'resetpw') {
const pw = prompt(`New password for ${username} (min 6 characters):`);
if (!pw) return;
if (pw.length < 6) { toast('Password too short.'); return; }
await postForm('/admin/users/reset-password', { user_id: userId, new_password: pw });
toast(`Password reset for ${username}.`);
} else if (act === 'disable') {
const disabling = btn.dataset.disabled !== '1';
if (disabling && !confirm(`Disable ${username}? They will be signed out and unable to log in.`)) return;
await postForm('/admin/users/disable', { user_id: userId, disabled: disabling ? '1' : '0' });
location.reload();
} else if (act === 'delete') {
if (!confirm(`Permanently delete ${username} and ALL their feeds, folders, and saved articles? This cannot be undone.`)) return;
if (prompt(`Type the username to confirm deletion:`) !== username) { toast('Deletion cancelled.'); return; }
await postForm('/admin/users/delete', { user_id: userId });
location.reload();
} else if (act === 'vacuum') {
const data = await (await postForm('/admin/users/vacuum', { user_id: userId })).json();
toast(data.mode === 'full'
? `Full vacuum started for ${username} — DB sizes update after it finishes (check Logs).`
: `Light vacuum started for ${username} (full skipped: ${(data.reasons || []).join('; ')}). Try the full one when they're idle.`);
}
} catch {
toast(`Action failed for ${username}.`);
}
});
// ── Logs tab ──────────────────────────────────────────────
const LOG_LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[,.]\d+) (DEBUG|INFO|WARNING|ERROR|CRITICAL) /;
const esc = (s) => s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
// Format a Date as the value a datetime-local input expects (local time).
function toLocalInput(d) {
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
// Highlight bracketed subsystem tags like [maintenance] / [refresh] /
// [deviantart] so they stand out. Runs on already-escaped text.
const tagify = (escaped) => escaped.replace(/\[([a-z][a-z0-9 _-]*)\]/gi, '<span class="log-tag">[$1]</span>');
function renderLogLines(lines) {
// Colorize by level; continuation lines (tracebacks) inherit the class
// of the record they belong to.
let currentClass = '';
return lines.map(line => {
const m = LOG_LINE_RE.exec(line);
if (m) {
const lvl = m[2].toLowerCase();
currentClass = (lvl === 'debug' || lvl === 'warning' || lvl === 'error' || lvl === 'critical')
? `lvl-${lvl}` : '';
const rest = tagify(esc(line.slice(m[0].length)));
return `<span class="${currentClass}"><span class="log-ts">${esc(m[1])}</span> ${esc(m[2])} ${rest}</span>`;
}
return `<span class="${currentClass}">${esc(line)}</span>`;
}).join('\n');
}
let _logsSeq = 0;
async function loadLogs() {
const seq = ++_logsSeq;
const out = $('adm-log-output');
const status = $('adm-log-status');
const level = $('adm-log-level')?.value || 'all';
const since = $('adm-log-since')?.value || '';
const until = $('adm-log-until')?.value || '';
const category = $('adm-log-category')?.value || 'all';
if (status) status.textContent = 'Loading…';
try {
const params = new URLSearchParams({ level, category });
if (since) params.set('since', since);
if (until) params.set('until', until);
const r = await fetch(`/admin/logs?${params.toString()}`, { credentials: 'same-origin' });
const d = await r.json();
if (seq !== _logsSeq) return; // a newer filter change superseded this request
if (!d.available) {
out.innerHTML = '';
status.textContent = 'File logging is not enabled (set LECTIO_LOG_DIR).';
return;
}
out.innerHTML = renderLogLines(d.lines || []);
const range = since && until ? `${since.replace('T', ' ')} → ${until.replace('T', ' ')}`
: since ? `since ${since.replace('T', ' ')}`
: until ? `until ${until.replace('T', ' ')}`
: 'most recent';
const cap = d.truncated ? ' — capped; narrow the range to see older lines' : '';
status.textContent = `${d.count} line${d.count === 1 ? '' : 's'} (${range})${cap}.`;
out.scrollTop = out.scrollHeight;
} catch {
if (seq === _logsSeq && status) status.textContent = 'Failed to load logs.';
}
}
// Presets set "Since" to N minutes ago, clear "Until" (= now), and reload.
document.querySelectorAll('.adm-log-preset').forEach(btn => {
btn.addEventListener('click', () => {
const mins = parseInt(btn.dataset.mins, 10) || 60;
const sinceInput = $('adm-log-since');
if (sinceInput) sinceInput.value = toLocalInput(new Date(Date.now() - mins * 60000));
if ($('adm-log-until')) $('adm-log-until').value = '';
loadLogs();
});
});
$('adm-log-refresh')?.addEventListener('click', loadLogs);
$('adm-log-level')?.addEventListener('change', loadLogs);
$('adm-log-category')?.addEventListener('change', loadLogs);
$('adm-log-since')?.addEventListener('change', loadLogs);
$('adm-log-until')?.addEventListener('change', loadLogs);
$('adm-log-wrap')?.addEventListener('change', (e) => {
$('adm-log-output')?.classList.toggle('wrap', e.target.checked);
});
// Lazy-load logs the first time the Logs tab is opened; default to the
// last 15 minutes so the view isn't the whole file.
let _logsLoaded = false;
document.querySelector('.adm-tab[data-panel="panel-logs"]')?.addEventListener('click', () => {
if (!_logsLoaded) {
_logsLoaded = true;
const sinceInput = $('adm-log-since');
if (sinceInput && !sinceInput.value) sinceInput.value = toLocalInput(new Date(Date.now() - 15 * 60000));
loadLogs();
}
});
})();
</script>
</body>
</html>