plugin logging
This commit is contained in:
@@ -737,6 +737,89 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="plugin-detail-view" class="hidden">
|
||||
<button id="plugin-detail-back" class="btn btn-secondary btn-sm" style="margin-bottom:14px;">
|
||||
<i class="fas fa-arrow-left"></i> <span data-i18n="admin.plugins_back">Back to plugins</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span id="plugin-detail-name"></span>
|
||||
</h2>
|
||||
<dl class="plugin-detail-meta" id="plugin-detail-meta"></dl>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-clock-rotate-left"></i> <span data-i18n="admin.plugins_retention_title">Log retention</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.plugins_retention_intro">
|
||||
Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="plugin-retention-days" data-i18n="admin.plugins_retention_days">Retention (days)</label>
|
||||
<input id="plugin-retention-days" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plugin-retention-max-mb" data-i18n="admin.plugins_retention_max_mb">Max log size (MB)</label>
|
||||
<input id="plugin-retention-max-mb" type="number" min="0" step="1" />
|
||||
</div>
|
||||
<button id="plugin-retention-save" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> <span data-i18n="admin.plugins_retention_save">Save retention</span>
|
||||
</button>
|
||||
<div id="plugin-retention-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-list"></i> <span data-i18n="admin.plugins_logs_title">Logs</span>
|
||||
</h2>
|
||||
<div class="plugin-logs-toolbar">
|
||||
<select id="plugin-logs-level" class="form-control">
|
||||
<option value="" data-i18n="admin.plugins_logs_level_all">All levels</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<input id="plugin-logs-search" type="search" class="form-control" data-i18n-placeholder="admin.plugins_logs_search" placeholder="Search messages…" />
|
||||
<label class="plugin-logs-live">
|
||||
<input id="plugin-logs-live" type="checkbox" checked />
|
||||
<span data-i18n="admin.plugins_logs_live">Live</span>
|
||||
</label>
|
||||
<button id="plugin-logs-refresh" class="btn btn-sm btn-secondary" title="Refresh">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<button id="plugin-logs-clear" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash-alt"></i> <span data-i18n="admin.plugins_logs_clear">Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.plugins_logs_col_time">Time</th>
|
||||
<th data-i18n="admin.plugins_logs_col_level">Level</th>
|
||||
<th data-i18n="admin.plugins_logs_col_kind">Kind</th>
|
||||
<th data-i18n="admin.plugins_logs_col_invocation">Invocation</th>
|
||||
<th data-i18n="admin.plugins_logs_col_message">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugin-logs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span id="plugin-logs-info" class="muted"></span>
|
||||
<button id="plugin-logs-prev" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button id="plugin-logs-next" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1149,3 +1149,89 @@ details[open] summary {
|
||||
margin-top: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Plugin detail page ── */
|
||||
.plugin-detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.plugin-detail-meta dt {
|
||||
color: var(--color-text-subtle);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.plugin-detail-meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2-5);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.plugin-logs-toolbar .form-control {
|
||||
width: auto;
|
||||
}
|
||||
.plugin-logs-toolbar #plugin-logs-search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.plugin-logs-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1-5);
|
||||
color: var(--color-text-subtle);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-log-level {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.plugin-log-level--debug {
|
||||
background: var(--color-border-light);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
.plugin-log-level--info {
|
||||
background: var(--color-badge-blue-bg);
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
.plugin-log-level--warn {
|
||||
background: var(--color-warning-bg-light);
|
||||
color: var(--color-badge-warning-text);
|
||||
}
|
||||
.plugin-log-level--error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-text-dark);
|
||||
}
|
||||
|
||||
.plugin-log-ts,
|
||||
.plugin-log-inv {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plugin-log-msg {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Brief highlight when a row arrives via the live stream. */
|
||||
.plugin-log-row--new {
|
||||
animation: plugin-log-flash 1.2s ease-out;
|
||||
}
|
||||
@keyframes plugin-log-flash {
|
||||
from {
|
||||
background: var(--color-badge-blue-bg);
|
||||
}
|
||||
to {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -756,6 +756,28 @@
|
||||
"plugins_installing": "Installing…",
|
||||
"plugins_installed": "Installed {{name}}.",
|
||||
"plugins_install_missing_bundle": "Select a plugin bundle (.zip).",
|
||||
"plugins_details": "Logs & details",
|
||||
"plugins_back": "Back to plugins",
|
||||
"plugins_retention_title": "Log retention",
|
||||
"plugins_retention_intro": "Rotated log segments older than the retention window, or beyond the size cap, are pruned on a schedule.",
|
||||
"plugins_retention_days": "Retention (days)",
|
||||
"plugins_retention_max_mb": "Max log size (MB)",
|
||||
"plugins_retention_save": "Save retention",
|
||||
"plugins_retention_saved": "Retention saved.",
|
||||
"plugins_retention_invalid": "Enter non-negative numbers.",
|
||||
"plugins_logs_title": "Logs",
|
||||
"plugins_logs_level_all": "All levels",
|
||||
"plugins_logs_search": "Search messages…",
|
||||
"plugins_logs_live": "Live",
|
||||
"plugins_logs_clear": "Clear",
|
||||
"plugins_logs_confirm_clear": "Clear all logs for this plugin?",
|
||||
"plugins_logs_none": "No log entries.",
|
||||
"plugins_logs_col_time": "Time",
|
||||
"plugins_logs_col_level": "Level",
|
||||
"plugins_logs_col_kind": "Kind",
|
||||
"plugins_logs_col_invocation": "Invocation",
|
||||
"plugins_logs_col_message": "Message",
|
||||
"plugins_logs_showing": "Showing {{from}}–{{to}} of {{total}}",
|
||||
"tab_smtp": "SMTP",
|
||||
"smtp_title": "Outbound Email (SMTP)",
|
||||
"smtp_intro": "SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.",
|
||||
|
||||
Reference in New Issue
Block a user