add frontend

This commit is contained in:
Bradley Nelson
2026-06-16 21:26:36 -06:00
parent 87d68c5b6f
commit 803150635c
32 changed files with 1654 additions and 152 deletions
+61
View File
@@ -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>
+196
View File
@@ -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);
});
+25
View File
@@ -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.",