plugin logging

This commit is contained in:
Bradley Nelson
2026-06-16 23:00:23 -06:00
parent 803150635c
commit 4427b1613b
17 changed files with 1928 additions and 12 deletions
+360 -1
View File
@@ -13,6 +13,17 @@ let usersPage = 0;
const PAGE_SIZE = 50;
let totalUsers = 0;
/* Plugin detail / log viewer state */
const PLUGIN_LOGS_PAGE_SIZE = 50;
/** @type {Record<string, PluginInfo>} */
let pluginsById = {};
/** @type {string|null} */
let pluginDetailId = null;
let pluginLogsPage = 0;
let pluginLogsTotal = 0;
/** @type {EventSource|null} */
let pluginLogStream = null;
/**
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
* @param {string} s
@@ -148,6 +159,8 @@ function switchTab(name, el) {
// visible — without this it would keep hitting the API every 2 s
// (and updating hidden DOM) for as long as a migration runs.
if (name !== 'storage') stopMigrationPolling();
// Leaving the plugins tab tears down the live log stream.
if (name !== 'plugins') stopLogStream();
if (name === 'users') loadUsers();
if (name === 'dashboard') loadDashboard();
if (name === 'storage') loadStorage();
@@ -1302,6 +1315,8 @@ async function loadPlugins() {
const disabledEl = document.getElementById('plugins-disabled');
const mainEl = document.getElementById('plugins-main');
if (!tbody || !disabledEl || !mainEl) return;
// Always return to the list view (and stop any live tail) when (re)loading.
closePluginDetail();
try {
const resp = await fetch(`${API}/admin/plugins`, {
headers: headers(),
@@ -1330,6 +1345,10 @@ async function loadPlugins() {
function renderPluginRows(plugins) {
const tbody = document.getElementById('plugins-tbody');
if (!tbody) return;
pluginsById = {};
plugins.forEach((p) => {
pluginsById[p.id] = p;
});
if (plugins.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}</td></tr>`;
return;
@@ -1346,6 +1365,9 @@ function renderPluginRows(plugins) {
const deleteBtn =
`<button class="btn btn-sm btn-danger plugin-action-btn" data-action="delete" data-pid="${_escJs(p.id)}" data-pname="${_escJs(p.name)}" title="${escapeHtml(i18n.t('admin.plugins_delete') || 'Delete')}">` +
'<i class="fas fa-trash-alt"></i></button>';
const detailsBtn =
`<button class="btn btn-sm btn-secondary plugin-action-btn" data-action="details" data-pid="${_escJs(p.id)}" title="${escapeHtml(i18n.t('admin.plugins_details') || 'Logs & details')}">` +
'<i class="fas fa-list"></i></button>';
return (
'<tr>' +
`<td>${escapeHtml(p.name)}</td>` +
@@ -1353,7 +1375,7 @@ function renderPluginRows(plugins) {
`<td>${escapeHtml(p.version)}</td>` +
`<td>${events}</td>` +
`<td>${statusBadge}</td>` +
`<td><div class="actions-row">${toggleBtn}${deleteBtn}</div></td>` +
`<td><div class="actions-row">${detailsBtn}${toggleBtn}${deleteBtn}</div></td>` +
'</tr>'
);
})
@@ -1364,6 +1386,7 @@ function renderPluginRows(plugins) {
const action = btn.dataset.action;
if (action === 'toggle') togglePlugin(btn.dataset.pid, btn.dataset.enabled !== 'true');
else if (action === 'delete') deletePlugin(btn.dataset.pid, btn.dataset.pname);
else if (action === 'details') openPluginDetail(btn.dataset.pid);
});
});
}
@@ -1470,6 +1493,315 @@ async function installPlugin() {
}
}
/* ── Plugin detail page (metadata + retention + logs + live tail) ── */
/**
* @typedef {Object} PluginLogEntry
* @property {string} ts
* @property {string} invocation_id
* @property {string} kind
* @property {string} level
* @property {string} [reason]
* @property {string} msg
*/
/**
* @typedef {Object} PluginLogPage
* @property {PluginLogEntry[]} entries
* @property {number} total
* @property {number} limit
* @property {number} offset
*/
/**
* Open the detail page for a plugin: metadata, retention form, and its logs
* (with a live tail). Hides the list view until the user goes back.
* @param {string|undefined} id
*/
function openPluginDetail(id) {
if (!id) return;
const plugin = pluginsById[id];
if (!plugin) return;
pluginDetailId = id;
pluginLogsPage = 0;
hideElement('plugins-main');
showElement('plugin-detail-view');
const nameEl = document.getElementById('plugin-detail-name');
if (nameEl) nameEl.textContent = plugin.name;
renderPluginMeta(plugin);
loadPluginRetention();
loadPluginLogs();
startLogStream(id);
}
/** Return to the installed-plugins list and stop the live stream. */
function closePluginDetail() {
stopLogStream();
pluginDetailId = null;
hideElement('plugin-detail-view');
showElement('plugins-main');
}
/** @param {PluginInfo} p */
function renderPluginMeta(p) {
const meta = document.getElementById('plugin-detail-meta');
if (!meta) return;
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
/** @param {string} label @param {string} value */
const row = (label, value) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`;
meta.innerHTML =
row(i18n.t('admin.plugins_col_id') || 'ID', `<code>${escapeHtml(p.id)}</code>`) +
row(i18n.t('admin.plugins_col_version') || 'Version', escapeHtml(p.version)) +
row('ABI', escapeHtml(String(p.abi))) +
row(i18n.t('admin.plugins_col_events') || 'Events', events) +
row(i18n.t('admin.plugins_col_status') || 'Status', statusBadge);
}
/** Read the current log filter from the toolbar inputs. */
function pluginLogFilter() {
const level = /** @type {HTMLSelectElement|null} */ (document.getElementById('plugin-logs-level'))?.value || '';
const search = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-search'))?.value || '';
return { level, search };
}
/** Load a page of the current plugin's logs into the table. */
async function loadPluginLogs() {
const id = pluginDetailId;
const tbody = document.getElementById('plugin-logs-tbody');
if (!id || !tbody) return;
const { level, search } = pluginLogFilter();
const params = new URLSearchParams();
params.set('limit', String(PLUGIN_LOGS_PAGE_SIZE));
params.set('offset', String(pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE));
if (level) params.set('level', level);
if (search) params.set('search', search);
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs?${params.toString()}`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
return;
}
/** @type {PluginLogPage} */
const page = await resp.json();
pluginLogsTotal = page.total;
renderPluginLogRows(page.entries || []);
updatePluginLogsPagination();
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
}
}
/** @param {PluginLogEntry[]} entries */
function renderPluginLogRows(entries) {
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}</td></tr>`;
return;
}
tbody.innerHTML = entries.map(pluginLogRowHtml).join('');
}
/** @param {PluginLogEntry} e */
function pluginLogRowHtml(e) {
const level = (e.level || 'info').toLowerCase();
const levelBadge = `<span class="plugin-log-level plugin-log-level--${escapeHtml(level)}">${escapeHtml(level)}</span>`;
const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log';
return (
'<tr>' +
`<td class="plugin-log-ts">${escapeHtml(timeAgo(e.ts))}</td>` +
`<td>${levelBadge}</td>` +
`<td><code>${escapeHtml(kind)}</code></td>` +
`<td><code class="plugin-log-inv">${escapeHtml(e.invocation_id)}</code></td>` +
`<td class="plugin-log-msg">${escapeHtml(e.msg)}</td>` +
'</tr>'
);
}
function updatePluginLogsPagination() {
const info = document.getElementById('plugin-logs-info');
const prev = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-prev'));
const next = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-next'));
if (info) {
if (pluginLogsTotal === 0) {
info.textContent = i18n.t('admin.plugins_logs_none') || 'No log entries.';
} else {
const from = pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE + 1;
const to = Math.min((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE, pluginLogsTotal);
info.textContent = i18n.t('admin.plugins_logs_showing', { from, to, total: pluginLogsTotal }) || `Showing ${from}–${to} of ${pluginLogsTotal}`;
}
}
if (prev) prev.disabled = pluginLogsPage === 0;
if (next) next.disabled = (pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE >= pluginLogsTotal;
}
/**
* Open the SSE live tail for the given plugin.
* @param {string} id
*/
function startLogStream(id) {
stopLogStream();
const live = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-live'));
if (live && !live.checked) return;
const es = new EventSource(`${API}/admin/plugins/${encodeURIComponent(id)}/logs/stream`, { withCredentials: true });
es.onmessage = (ev) => {
try {
/** @type {PluginLogEntry} */
const entry = JSON.parse(ev.data);
onLiveLogEntry(entry);
} catch {
/* ignore malformed frames */
}
};
es.addEventListener('lagged', () => {
// Fell behind the broadcast buffer — resync from the server.
loadPluginLogs();
});
pluginLogStream = es;
}
/** Close the live tail if open. */
function stopLogStream() {
if (pluginLogStream) {
pluginLogStream.close();
pluginLogStream = null;
}
}
/**
* Handle a streamed entry: only prepend when viewing the newest page and the
* entry passes the active filter, so the live tail never fights pagination.
* @param {PluginLogEntry} entry
*/
function onLiveLogEntry(entry) {
if (pluginLogsPage !== 0) return;
const { level, search } = pluginLogFilter();
if (level && (entry.level || '').toLowerCase() !== level.toLowerCase()) return;
if (search && !(entry.msg || '').toLowerCase().includes(search.toLowerCase())) return;
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
// Drop any "empty" placeholder row before inserting the first live entry.
const placeholder = tbody.querySelector('.table-status-empty');
if (placeholder) tbody.innerHTML = '';
tbody.insertAdjacentHTML('afterbegin', pluginLogRowHtml(entry));
const firstRow = tbody.firstElementChild;
if (firstRow) firstRow.classList.add('plugin-log-row--new');
// Keep the page bounded to one page-worth of rows.
while (tbody.children.length > PLUGIN_LOGS_PAGE_SIZE) {
const last = tbody.lastElementChild;
if (!last) break;
last.remove();
}
pluginLogsTotal += 1;
updatePluginLogsPagination();
}
/** Load the current plugin's retention into the form. */
async function loadPluginRetention() {
const id = pluginDetailId;
if (!id) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) return;
/** @type {{retention_days: number, max_bytes: number}} */
const r = await resp.json();
const daysEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-days'));
const mbEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-max-mb'));
if (daysEl) daysEl.value = String(r.retention_days);
if (mbEl) mbEl.value = String(Math.round(r.max_bytes / (1024 * 1024)));
} catch {
/* leave fields as-is on error */
}
}
/** Persist the retention form for the current plugin. */
async function savePluginRetention() {
const id = pluginDetailId;
const resultEl = document.getElementById('plugin-retention-result');
if (!id || !resultEl) return;
const days = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-days')).value);
const mb = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-max-mb')).value);
if (!Number.isFinite(days) || days < 0 || !Number.isFinite(mb) || mb < 0) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_invalid') || 'Enter non-negative numbers.';
return;
}
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ retention_days: Math.round(days), max_bytes: Math.round(mb) * 1024 * 1024 })
});
if (resp.ok) {
resultEl.className = 'alert alert-success';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_saved') || 'Retention saved.';
} else {
const e = await resp.json().catch(() => ({}));
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = e.message || `HTTP ${resp.status}`;
}
} catch (e) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
}
}
/** Clear all persisted logs for the current plugin. */
async function clearPluginLogs() {
const id = pluginDetailId;
if (!id) return;
const ok = await showConfirm(i18n.t('admin.plugins_logs_confirm_clear') || 'Clear all logs for this plugin?');
if (!ok) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs`, {
method: 'DELETE',
headers: headers(),
credentials: 'same-origin'
});
if (resp.ok) {
pluginLogsPage = 0;
loadPluginLogs();
} else {
const e = await resp.json().catch(() => ({}));
alert(e.message || i18n.t('admin.error_generic'));
}
} catch (e) {
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
}
}
function pluginLogsPrevPage() {
if (pluginLogsPage > 0) {
pluginLogsPage--;
loadPluginLogs();
}
}
function pluginLogsNextPage() {
if ((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE < pluginLogsTotal) {
pluginLogsPage++;
loadPluginLogs();
}
}
/* ── Apply i18n when translations load / change ── */
document.addEventListener('translationsLoaded', () => {
i18n.translatePage();
@@ -1510,6 +1842,33 @@ document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest)
document.getElementById('btn-plugin-install').addEventListener('click', installPlugin);
/* Plugin detail page controls */
document.getElementById('plugin-detail-back')?.addEventListener('click', closePluginDetail);
document.getElementById('plugin-retention-save')?.addEventListener('click', savePluginRetention);
document.getElementById('plugin-logs-refresh')?.addEventListener('click', loadPluginLogs);
document.getElementById('plugin-logs-clear')?.addEventListener('click', clearPluginLogs);
document.getElementById('plugin-logs-prev')?.addEventListener('click', pluginLogsPrevPage);
document.getElementById('plugin-logs-next')?.addEventListener('click', pluginLogsNextPage);
document.getElementById('plugin-logs-level')?.addEventListener('change', () => {
pluginLogsPage = 0;
loadPluginLogs();
});
let pluginLogsSearchTimer = 0;
document.getElementById('plugin-logs-search')?.addEventListener('input', () => {
window.clearTimeout(pluginLogsSearchTimer);
pluginLogsSearchTimer = window.setTimeout(() => {
pluginLogsPage = 0;
loadPluginLogs();
}, 250);
});
document.getElementById('plugin-logs-live')?.addEventListener('change', function () {
if (/** @type {HTMLInputElement} */ (this).checked) {
if (pluginDetailId) startLogStream(pluginDetailId);
} else {
stopLogStream();
}
});
document.getElementById('ds-registration').addEventListener('change', function () {
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
});