add frontend
This commit is contained in:
@@ -65,6 +65,9 @@
|
||||
<button class="admin-tab" id="tab-btn-smtp">
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="admin.tab_smtp">SMTP</span>
|
||||
</button>
|
||||
<button class="admin-tab" id="tab-btn-plugins">
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.tab_plugins">Plugins</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
@@ -677,6 +680,64 @@
|
||||
<div id="smtp-test-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-plugins" class="tab-content">
|
||||
<div id="plugins-disabled" class="admin-card hidden">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_title">Plugins</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.plugins_disabled">
|
||||
Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the "plugins" feature) to manage WASM plugins here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="plugins-main" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install_title">Install a plugin</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.plugins_install_intro">
|
||||
Upload a plugin bundle (.zip) containing plugin.toml and its compiled WebAssembly module (.wasm). The manifest is validated and the module is probed before installation.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="plugin-bundle-file" data-i18n="admin.plugins_bundle_label">Plugin bundle (.zip)</label>
|
||||
<input id="plugin-bundle-file" type="file" accept=".zip,application/zip" />
|
||||
</div>
|
||||
<button id="btn-plugin-install" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> <span data-i18n="admin.plugins_install">Install plugin</span>
|
||||
</button>
|
||||
<div id="plugin-install-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-puzzle-piece"></i> <span data-i18n="admin.plugins_installed_title">Installed plugins</span>
|
||||
</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.plugins_col_name">Name</th>
|
||||
<th data-i18n="admin.plugins_col_id">ID</th>
|
||||
<th data-i18n="admin.plugins_col_version">Version</th>
|
||||
<th data-i18n="admin.plugins_col_events">Events</th>
|
||||
<th data-i18n="admin.plugins_col_status">Status</th>
|
||||
<th data-i18n="admin.plugins_col_actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugins-tbody">
|
||||
<tr>
|
||||
<td colspan="6" class="table-loading-cell">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span data-i18n="admin.plugins_loading">Loading plugins…</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ function switchTab(name, el) {
|
||||
if (name === 'dashboard') loadDashboard();
|
||||
if (name === 'storage') loadStorage();
|
||||
if (name === 'smtp') loadSmtp();
|
||||
if (name === 'plugins') loadPlugins();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -1280,6 +1281,195 @@ async function sendSmtpTest() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Plugins ── */
|
||||
|
||||
/**
|
||||
* @typedef {Object} PluginInfo
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {string} version
|
||||
* @property {number} abi
|
||||
* @property {string[]} subscriptions
|
||||
* @property {boolean} enabled
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load installed plugins into the table. A 503 means plugins are disabled on
|
||||
* the server — show the explanatory banner instead of the management UI.
|
||||
*/
|
||||
async function loadPlugins() {
|
||||
const tbody = document.getElementById('plugins-tbody');
|
||||
const disabledEl = document.getElementById('plugins-disabled');
|
||||
const mainEl = document.getElementById('plugins-main');
|
||||
if (!tbody || !disabledEl || !mainEl) return;
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/plugins`, {
|
||||
headers: headers(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.status === 503) {
|
||||
disabledEl.classList.remove('hidden');
|
||||
mainEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
disabledEl.classList.add('hidden');
|
||||
mainEl.classList.remove('hidden');
|
||||
if (!resp.ok) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
/** @type {{enabled: boolean, plugins: PluginInfo[]}} */
|
||||
const data = await resp.json();
|
||||
renderPluginRows(data.plugins || []);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" 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 {PluginInfo[]} plugins */
|
||||
function renderPluginRows(plugins) {
|
||||
const tbody = document.getElementById('plugins-tbody');
|
||||
if (!tbody) return;
|
||||
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;
|
||||
}
|
||||
tbody.innerHTML = plugins
|
||||
.map((p) => {
|
||||
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>`;
|
||||
const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable';
|
||||
const toggleBtn =
|
||||
`<button class="btn btn-sm ${p.enabled ? 'btn-secondary' : 'btn-success'} plugin-action-btn" data-action="toggle" data-pid="${_escJs(p.id)}" data-enabled="${p.enabled}" title="${escapeHtml(toggleTitle)}">` +
|
||||
`<i class="fas fa-${p.enabled ? 'pause' : 'play'}"></i></button>`;
|
||||
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>';
|
||||
return (
|
||||
'<tr>' +
|
||||
`<td>${escapeHtml(p.name)}</td>` +
|
||||
`<td><code>${escapeHtml(p.id)}</code></td>` +
|
||||
`<td>${escapeHtml(p.version)}</td>` +
|
||||
`<td>${events}</td>` +
|
||||
`<td>${statusBadge}</td>` +
|
||||
`<td><div class="actions-row">${toggleBtn}${deleteBtn}</div></td>` +
|
||||
'</tr>'
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
/** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('#plugins-tbody .plugin-action-btn')).forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} id
|
||||
* @param {boolean} enable
|
||||
*/
|
||||
async function togglePlugin(id, enable) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/enabled`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ enabled: enable })
|
||||
});
|
||||
if (resp.ok) {
|
||||
loadPlugins();
|
||||
} 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} id
|
||||
* @param {string|undefined} name
|
||||
*/
|
||||
async function deletePlugin(id, name) {
|
||||
if (!id) return;
|
||||
const ok = await showConfirm(i18n.t('admin.plugins_confirm_delete', { name: name || id }));
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
loadPlugins();
|
||||
} 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from the selected .zip bundle. The multipart Content-Type
|
||||
* (with its boundary) is set by the browser — do not override it.
|
||||
*/
|
||||
async function installPlugin() {
|
||||
const bundleInput = /** @type {HTMLInputElement | null} */ (document.getElementById('plugin-bundle-file'));
|
||||
const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-plugin-install'));
|
||||
const resultEl = document.getElementById('plugin-install-result');
|
||||
if (!bundleInput || !resultEl) return;
|
||||
|
||||
const bundleFile = bundleInput.files?.[0];
|
||||
if (!bundleFile) {
|
||||
resultEl.className = 'alert alert-error';
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.textContent = i18n.t('admin.plugins_install_missing_bundle') || 'Select a plugin bundle (.zip).';
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('bundle', bundleFile);
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
resultEl.className = 'alert alert-info';
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.textContent = i18n.t('admin.plugins_installing') || 'Installing…';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API}/admin/plugins`, {
|
||||
method: 'POST',
|
||||
headers: { ...getCsrfHeaders() },
|
||||
credentials: 'same-origin',
|
||||
body: form
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const e = await resp.json().catch(() => ({}));
|
||||
resultEl.className = 'alert alert-error';
|
||||
resultEl.textContent = e.message || `HTTP ${resp.status}`;
|
||||
return;
|
||||
}
|
||||
/** @type {PluginInfo} */
|
||||
const info = await resp.json();
|
||||
resultEl.className = 'alert alert-success';
|
||||
resultEl.textContent = i18n.t('admin.plugins_installed', { name: info.name }) || `Installed ${info.name}.`;
|
||||
bundleInput.value = '';
|
||||
loadPlugins();
|
||||
} catch (e) {
|
||||
resultEl.className = 'alert alert-error';
|
||||
resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Apply i18n when translations load / change ── */
|
||||
document.addEventListener('translationsLoaded', () => {
|
||||
i18n.translatePage();
|
||||
@@ -1312,8 +1502,14 @@ document.getElementById('tab-btn-smtp').addEventListener('click', function () {
|
||||
switchTab('smtp', this);
|
||||
});
|
||||
|
||||
document.getElementById('tab-btn-plugins').addEventListener('click', function () {
|
||||
switchTab('plugins', this);
|
||||
});
|
||||
|
||||
document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest);
|
||||
|
||||
document.getElementById('btn-plugin-install').addEventListener('click', installPlugin);
|
||||
|
||||
document.getElementById('ds-registration').addEventListener('change', function () {
|
||||
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
|
||||
});
|
||||
|
||||
@@ -731,6 +731,31 @@
|
||||
"migration_verify_failed": "Verification failed",
|
||||
"migration_failed_blobs": "failed blobs",
|
||||
"testing": "Testing…",
|
||||
"tab_plugins": "Plugins",
|
||||
"plugins_title": "Plugins",
|
||||
"plugins_disabled": "Plugins are disabled on this server. Set OXICLOUD_ENABLE_PLUGINS=true (and build with the \"plugins\" feature) to manage WASM plugins here.",
|
||||
"plugins_install_title": "Install a plugin",
|
||||
"plugins_install_intro": "Upload a plugin bundle (.zip) containing plugin.toml and its compiled WebAssembly module (.wasm). The manifest is validated and the module is probed before installation.",
|
||||
"plugins_bundle_label": "Plugin bundle (.zip)",
|
||||
"plugins_install": "Install plugin",
|
||||
"plugins_installed_title": "Installed plugins",
|
||||
"plugins_col_name": "Name",
|
||||
"plugins_col_id": "ID",
|
||||
"plugins_col_version": "Version",
|
||||
"plugins_col_events": "Events",
|
||||
"plugins_col_status": "Status",
|
||||
"plugins_col_actions": "Actions",
|
||||
"plugins_loading": "Loading plugins…",
|
||||
"plugins_none": "No plugins installed.",
|
||||
"plugins_enabled": "Enabled",
|
||||
"plugins_disabled_badge": "Disabled",
|
||||
"plugins_enable": "Enable",
|
||||
"plugins_disable": "Disable",
|
||||
"plugins_delete": "Delete",
|
||||
"plugins_confirm_delete": "Delete plugin \"{{name}}\"? Its files will be removed from the server.",
|
||||
"plugins_installing": "Installing…",
|
||||
"plugins_installed": "Installed {{name}}.",
|
||||
"plugins_install_missing_bundle": "Select a plugin bundle (.zip).",
|
||||
"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