plugin logging
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user