Files

414 lines
19 KiB
Plaintext

<%- include('top') %>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-plug"></i> Plugins
</div>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewPluginModal()">
<i class="fas fa-plus"></i> New Plugin
</button>
</div>
</div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Configured plugin instances. Each is a loadable, scheduled copy of a
plugin type (e.g. Proxmox, UniFi, Nmap) — you can run several of the same type with different settings.
Secrets are stored in OpenBao and shown masked. <a href="/docs/plugins">Learn more</a>.
</div>
<div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Name</th>
<th>Type</th>
<th>Schedule</th>
<th>State</th>
<th>Last Run</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="plugins-list" jq-repeat="plugins">
<tr id="plugin-row-{{id}}">
<td class="ps-3">
<strong>{{name}}</strong>
<div class="small text-muted font-monospace">{{slug}}</div>
</td>
<td><span class="badge bg-secondary">{{pluginType}}</span></td>
<td><code class="text-dark">{{cron}}</code></td>
<td>
{{#enabled}}<span class="badge bg-success">Loaded</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Unloaded</span>{{/enabled}}
</td>
<td class="small">
{{#lastRunAt}}<span title="{{lastRunAt}}">{{lastRunFmt}}</span>{{/lastRunAt}}
{{^lastRunAt}}<span class="text-muted">never</span>{{/lastRunAt}}
{{#lastStatus}}
{{#isOk}}<span class="badge bg-success-subtle text-success-emphasis ms-1">ok</span>{{/isOk}}
{{#isError}}<span class="badge bg-danger-subtle text-danger-emphasis ms-1" title="{{lastError}}">error</span>{{/isError}}
{{#isRunning}}<span class="badge bg-info-subtle text-info-emphasis ms-1">running</span>{{/isRunning}}
{{/lastStatus}}
</td>
<td>
<button class="btn btn-sm btn-primary" title="Edit" onclick="openEditModal('{{id}}')"><i class="fa-solid fa-pen"></i></button>
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
</tbody>
<tbody id="plugins-empty-state" style="display: none;">
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="fa-solid fa-plug fs-2 mb-3 text-black-50"></i>
<h5>No plugin instances</h5>
<p>Click <strong>New Plugin</strong> to configure one.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
// type -> manifest (configSchema etc.), loaded once for the New-plugin form.
var pluginTypes = {};
// id -> instance (plain), kept current after each load so modals can resolve a row.
var pluginsById = {};
$(document).ready(function() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + (err.message || err), 'danger'); return; }
(res.results || []).forEach(function(t) { pluginTypes[t.type] = t; });
});
loadPlugins();
});
function fmtRun(ms) {
if (!ms) return '';
var d = new Date(Number(ms));
return moment(d).fromNow();
}
function loadPlugins() {
app.api.get('plugins', function(err, res) {
if (err) { app.messages.toast('Error loading plugins: ' + (err.message || err), 'danger'); return; }
var list = res.results || [];
pluginsById = {};
$.scope.plugins.empty();
if (!list.length) {
$('#plugins-list').hide();
$('#plugins-empty-state').show();
} else {
list.forEach(function(p) {
pluginsById[p.id] = p;
p.lastRunFmt = fmtRun(p.lastRunAt);
p.isOk = p.lastStatus === 'ok';
p.isError = p.lastStatus === 'error';
p.isRunning = p.lastStatus === 'running';
$.scope.plugins.push(p);
});
$('#plugins-list').show();
$('#plugins-empty-state').hide();
}
});
}
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {};
var html = '';
schema.forEach(function(f) {
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3">' +
'<label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
});
return html;
}
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
});
return out;
}
function typeOptionsHtml(selected) {
var opts = '<option value="">Select a plugin type…</option>';
Object.keys(pluginTypes).sort().forEach(function(t) {
opts += '<option value="' + t + '"' + (t === selected ? ' selected' : '') + '>' + pluginTypes[t].name + ' (' + t + ')</option>';
});
return opts;
}
// --- New Plugin modal ---
function openNewPluginModal() {
app.modal.open({
title: 'New Plugin',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Plugin Type <span class="text-danger">*</span></label>' +
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('np-', '0 * * * *') +
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
});
}
function renderNewPluginFields() {
var type = document.getElementById('np-type').value;
document.getElementById('np-config-fields').innerHTML = configFormHtml(type, 'np-');
}
async function saveNewPlugin() {
var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim();
var cron = cronFromForm('np-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-');
try {
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to create plugin', app.modal.body(), 'danger');
}
}
// --- Edit (non-secret) modal ---
function openEditModal(id) {
var p = pluginsById[id];
if (!p) return;
app.modal.open({
title: 'Edit — ' + p.name,
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit(\'' + id + '\')', saveLabel: 'Save' })
}
});
}
async function saveEdit(id) {
var p = pluginsById[id];
if (!p) return;
var name = document.getElementById('ed-name').value.trim();
var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-');
try {
await app.api.put('plugins/' + id, { name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin saved.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
// --- Edit Secrets modal ---
function openSecretsModal(id) {
var p = pluginsById[id];
if (!p) return;
var masked = p.secrets || {};
// Render only the secret fields, prefilled with the masked values.
var schema = (pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema) || [];
var secretFields = schema.filter(function(f) { return f.secret; });
var html = '<div class="actionMessage mb-3" style="display:none"></div>' +
'<p class="text-muted small">Stored in OpenBao. Leave a field blank to keep its current value.</p>';
if (!secretFields.length) {
html += '<p class="text-muted">This plugin has no secret fields.</p>';
} else {
secretFields.forEach(function(f) {
var val = masked[f.key] || '';
html += '<div class="mb-3"><label class="form-label">' + f.label + '</label>' +
'<input type="password" class="form-control" id="sec-' + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '" placeholder="' + (val ? '******** (unchanged)' : 'new value') + '"></div>';
});
}
app.modal.open({
title: 'Edit Secrets — ' + p.name,
bodyHtml: html,
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
});
}
async function saveSecrets(id) {
var p = pluginsById[id];
if (!p) return;
var schema = pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema;
var secrets = {};
if (schema) {
schema.forEach(function(f) {
if (!f.secret) return;
var el = document.getElementById('sec-' + f.key);
if (el) secrets[f.key] = el.value;
});
}
try {
await app.api.put('plugins/' + id + '/secrets', secrets);
app.modal.close();
app.messages.toast('Secrets saved.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to save secrets', app.modal.body(), 'danger');
}
}
async function testPlugin(id) {
try {
var res = await app.api.post('plugins/' + id + '/test', {});
app.messages.toast('Test passed.', 'success');
} catch (err) {
app.messages.toast('Test failed: ' + (err.message || 'validation failed'), 'danger');
}
}
async function runNow(id) {
try {
await app.api.post('plugins/' + id + '/run', {});
app.messages.toast('Run enqueued. Refresh shortly for status.', 'info');
setTimeout(loadPlugins, 3000);
} catch (err) {
app.messages.toast('Failed to run: ' + (err.message || err), 'danger');
}
}
async function showLogs(id) {
var p = pluginsById[id];
if (!p) return;
try {
const res = await app.api.get('plugins/' + id + '/runs');
const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.';
app.modal.open({
title: 'Logs — ' + p.name,
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
});
} catch (err) {
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
}
}
async function togglePlugin(id, enable) {
try {
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
app.messages.toast(enable ? 'Plugin loaded.' : 'Plugin unloaded.', 'success');
loadPlugins();
} catch (err) {
app.messages.toast('Failed: ' + (err.message || err), 'danger');
}
}
async function deletePlugin(id) {
var p = pluginsById[id];
if (!p) return;
var ok = await app.messages.confirm('Delete plugin "' + p.name + '"? Its schedule and OpenBao secrets will be removed.', app.modal.body ? app.modal.body() : null, 'danger');
if (!ok) return;
try {
await app.api.delete('plugins/' + id);
app.messages.toast('Plugin deleted.', 'success');
loadPlugins();
} catch (err) {
app.messages.toast('Failed to delete: ' + (err.message || err), 'danger');
}
}
</script>
<%- include('bottom') %>