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
+83
View File
@@ -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>
+86
View File
@@ -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;
}
}
+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);
});
+22
View File
@@ -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.",