mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-02 00:39:28 +00:00
880 lines
38 KiB
HTML
880 lines
38 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Plugins - MeshCore Bot{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.plugins-toolbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
background-color: var(--bg-color, #fff);
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
[data-theme="dark"] .plugins-toolbar { background-color: var(--bg-color, #1a1a1a); }
|
|
|
|
.plugin-card {
|
|
border: 1px solid var(--border-color);
|
|
background-color: var(--card-bg);
|
|
border-left: 3px solid #0d6efd;
|
|
}
|
|
[data-theme="dark"] .plugin-card { border-left-color: #6ea8fe; }
|
|
.plugin-card.disabled { border-left-color: var(--border-color); }
|
|
.plugin-card.disabled .plugin-title { color: var(--text-muted, #6c757d); }
|
|
|
|
/* Compact, clickable header */
|
|
.plugin-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
cursor: pointer;
|
|
padding: 0.5rem 0.85rem;
|
|
user-select: none;
|
|
}
|
|
.plugin-header .form-switch { cursor: pointer; }
|
|
.plugin-title { font-weight: 600; }
|
|
.plugin-headtext { min-width: 0; } /* allow truncation */
|
|
.plugin-desc {
|
|
color: var(--text-muted, #6c757d);
|
|
font-size: 0.8rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.plugin-section-label {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-size: 0.72rem;
|
|
color: var(--text-muted, #6c757d);
|
|
margin-left: 0.4rem;
|
|
}
|
|
.plugin-chevron { color: var(--text-muted, #6c757d); transition: transform 0.15s ease; }
|
|
.plugin-card.expanded .plugin-chevron { transform: rotate(180deg); }
|
|
|
|
.plugin-save-status { margin-left: 0.5rem; font-size: 0.85rem; }
|
|
.plugin-group-heading { margin: 1.25rem 0 0.6rem; }
|
|
.form-text { font-size: 0.76rem; margin-top: 0.1rem; }
|
|
.plugin-card .card-body { padding: 0.85rem; }
|
|
.plugin-card .form-label { margin-bottom: 0.2rem; font-size: 0.85rem; }
|
|
.restart-note { font-size: 0.78rem; color: #b8860b; }
|
|
[data-theme="dark"] .restart-note { color: #e0b84d; }
|
|
.plugin-count-badge { font-size: 0.7rem; }
|
|
.dynamic-section { border-top: 1px solid var(--border-color); padding-top: 0.75rem; }
|
|
.dynamic-row .dyn-key { font-family: var(--bs-font-monospace, monospace); }
|
|
.repeating-section { border-top: 1px solid var(--border-color); padding-top: 0.75rem; }
|
|
.rb-block { border: 1px solid var(--border-color); background-color: var(--bg-secondary, #f8f9fa); }
|
|
[data-theme="dark"] .rb-block { background-color: rgba(255,255,255,0.03); }
|
|
.rb-block .card-header { background-color: transparent; }
|
|
.field-group > summary {
|
|
cursor: pointer;
|
|
padding: 0.35rem 0;
|
|
border-top: 1px dashed var(--border-color);
|
|
list-style-position: inside;
|
|
}
|
|
.field-group > summary:hover { color: var(--text-color); }
|
|
.section-badge {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-weight: 500;
|
|
font-size: 0.66rem;
|
|
color: var(--text-muted, #6c757d);
|
|
background-color: var(--bg-secondary, #f1f3f5);
|
|
border: 1px solid var(--border-color);
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h1 class="h3 mb-0"><i class="fas fa-puzzle-piece me-2"></i>Plugins</h1>
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-refresh-btn">
|
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
|
</button>
|
|
</div>
|
|
<p class="text-muted small mb-2">
|
|
Toggle commands and services on or off and edit their settings. Changes are
|
|
written to <code>config.ini</code> (a timestamped backup is taken on each save)
|
|
and the bot hot-reloads command settings within a few seconds.
|
|
</p>
|
|
|
|
<div class="plugins-toolbar mb-3">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-12 col-md">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
|
<input type="text" class="form-control" id="plugins-filter"
|
|
placeholder="Filter by name, section, or description…" autocomplete="off">
|
|
<button class="btn btn-outline-secondary" id="plugins-filter-clear" title="Clear">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="plugins-enabled-only">
|
|
<label class="form-check-label small" for="plugins-enabled-only">Enabled only</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-expand-all">
|
|
<i class="fas fa-angle-double-down me-1"></i>Expand all
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-collapse-all">
|
|
<i class="fas fa-angle-double-up me-1"></i>Collapse all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="plugins-loading" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<div class="mt-2 text-muted">Loading plugins…</div>
|
|
</div>
|
|
<div id="plugins-error" class="alert alert-danger d-none"></div>
|
|
<div id="plugins-empty" class="text-muted fst-italic d-none py-3">No plugins match your filter.</div>
|
|
|
|
<div id="commands-group" class="d-none">
|
|
<h2 class="h5 plugin-group-heading">
|
|
<i class="fas fa-terminal me-2"></i>Commands
|
|
<span class="badge bg-secondary plugin-count-badge" id="commands-count"></span>
|
|
</h2>
|
|
<div id="commands-list"></div>
|
|
</div>
|
|
<div id="services-group" class="d-none">
|
|
<h2 class="h5 plugin-group-heading">
|
|
<i class="fas fa-server me-2"></i>Services
|
|
<span class="badge bg-secondary plugin-count-badge" id="services-count"></span>
|
|
</h2>
|
|
<div id="services-list"></div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
class PluginsManager {
|
|
constructor() {
|
|
this.loadingEl = document.getElementById('plugins-loading');
|
|
this.errorEl = document.getElementById('plugins-error');
|
|
this.emptyEl = document.getElementById('plugins-empty');
|
|
this.commandsGroup = document.getElementById('commands-group');
|
|
this.servicesGroup = document.getElementById('services-group');
|
|
this.commandsList = document.getElementById('commands-list');
|
|
this.servicesList = document.getElementById('services-list');
|
|
this.commandsCount = document.getElementById('commands-count');
|
|
this.servicesCount = document.getElementById('services-count');
|
|
this.filterInput = document.getElementById('plugins-filter');
|
|
this.enabledOnly = document.getElementById('plugins-enabled-only');
|
|
this.cards = []; // { el, collapse, kind, enabled, searchText }
|
|
|
|
document.getElementById('plugins-refresh-btn').addEventListener('click', () => this.load());
|
|
document.getElementById('plugins-expand-all').addEventListener('click', () => this.setAll(true));
|
|
document.getElementById('plugins-collapse-all').addEventListener('click', () => this.setAll(false));
|
|
document.getElementById('plugins-filter-clear').addEventListener('click', () => {
|
|
this.filterInput.value = ''; this.applyFilter();
|
|
});
|
|
this.filterInput.addEventListener('input', () => this.applyFilter());
|
|
this.enabledOnly.addEventListener('change', () => this.applyFilter());
|
|
}
|
|
|
|
async load() {
|
|
this.loadingEl.classList.remove('d-none');
|
|
this.errorEl.classList.add('d-none');
|
|
this.commandsList.innerHTML = '';
|
|
this.servicesList.innerHTML = '';
|
|
this.cards = [];
|
|
try {
|
|
const resp = await fetch('/api/plugins');
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to load plugins');
|
|
const plugins = data.plugins || [];
|
|
const commands = plugins.filter(p => p.kind === 'command');
|
|
const services = plugins.filter(p => p.kind === 'service');
|
|
commands.forEach(p => this.commandsList.appendChild(this.renderCard(p)));
|
|
services.forEach(p => this.servicesList.appendChild(this.renderCard(p)));
|
|
this.commandsCount.textContent = commands.length;
|
|
this.servicesCount.textContent = services.length;
|
|
this.commandsGroup.classList.toggle('d-none', commands.length === 0);
|
|
this.servicesGroup.classList.toggle('d-none', services.length === 0);
|
|
this.applyFilter();
|
|
} catch (err) {
|
|
this.errorEl.textContent = 'Error: ' + err.message;
|
|
this.errorEl.classList.remove('d-none');
|
|
} finally {
|
|
this.loadingEl.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
setAll(expand) {
|
|
this.cards.forEach(c => {
|
|
// Expand only affects visible cards (don't blow open a filtered list);
|
|
// collapse clears everything so nothing stays open while hidden.
|
|
if (expand) {
|
|
if (c.el.classList.contains('d-none')) return;
|
|
c.collapse.show(); c.el.classList.add('expanded');
|
|
} else {
|
|
c.collapse.hide(); c.el.classList.remove('expanded');
|
|
}
|
|
});
|
|
}
|
|
|
|
applyFilter() {
|
|
const q = this.filterInput.value.trim().toLowerCase();
|
|
const enabledOnly = this.enabledOnly.checked;
|
|
let visibleCommands = 0, visibleServices = 0;
|
|
this.cards.forEach(c => {
|
|
const matches = (!q || c.searchText.includes(q)) && (!enabledOnly || c.enabled);
|
|
c.el.classList.toggle('d-none', !matches);
|
|
if (matches) { c.kind === 'command' ? visibleCommands++ : visibleServices++; }
|
|
});
|
|
this.commandsCount.textContent = visibleCommands;
|
|
this.servicesCount.textContent = visibleServices;
|
|
this.commandsGroup.classList.toggle('d-none', visibleCommands === 0);
|
|
this.servicesGroup.classList.toggle('d-none', visibleServices === 0);
|
|
const nothing = (visibleCommands + visibleServices) === 0 && this.cards.length > 0;
|
|
this.emptyEl.classList.toggle('d-none', !nothing);
|
|
}
|
|
|
|
// Render a single plugin card (collapsed by default).
|
|
renderCard(plugin) {
|
|
const card = document.createElement('section');
|
|
card.className = 'card mb-2 plugin-card' + (plugin.enabled ? '' : ' disabled');
|
|
|
|
const toggleId = `enable-${plugin.kind}-${plugin.name}`;
|
|
const bodyId = `body-${plugin.kind}-${plugin.name}`;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'plugin-header';
|
|
header.innerHTML = `
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="${toggleId}" ${plugin.enabled ? 'checked' : ''}>
|
|
</div>
|
|
<div class="flex-grow-1 plugin-headtext">
|
|
<span class="plugin-title">${escapeHtml(plugin.label)}</span>
|
|
<span class="plugin-section-label">[${escapeHtml(plugin.section)}]</span>
|
|
<div class="plugin-desc">${escapeHtml(plugin.description || '')}</div>
|
|
</div>
|
|
<i class="fas fa-chevron-down plugin-chevron"></i>
|
|
`;
|
|
card.appendChild(header);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'collapse';
|
|
body.id = bodyId;
|
|
|
|
const inner = document.createElement('div');
|
|
inner.className = 'card-body border-top';
|
|
|
|
const form = document.createElement('form');
|
|
form.setAttribute('novalidate', '');
|
|
|
|
// Inputs keyed by config key, with their schema field for validation.
|
|
const inputs = [];
|
|
|
|
// Field grid: dense responsive columns sized to field type.
|
|
const grid = document.createElement('div');
|
|
grid.className = 'row g-2';
|
|
form.appendChild(grid);
|
|
|
|
const addField = (field, value, targetGrid = grid) => {
|
|
const col = document.createElement('div');
|
|
col.className = fieldColClass(field);
|
|
const { wrapper, input } = renderField(field, value);
|
|
col.appendChild(wrapper);
|
|
targetGrid.appendChild(col);
|
|
// Capture the original value so saves only send changed fields
|
|
// (keeps config.ini minimal and preserves preset-driven defaults).
|
|
inputs.push({ field, input, original: readInput(field, input) });
|
|
};
|
|
|
|
// Render grouped fields ("group" attr) inside a collapsed <details> so
|
|
// advanced settings are present but tucked away.
|
|
const addGroupedFields = (fields) => {
|
|
const groupNames = [...new Set(fields.map(f => f.group))];
|
|
groupNames.forEach(gname => {
|
|
const details = document.createElement('details');
|
|
details.className = 'col-12 field-group mt-1';
|
|
const summary = document.createElement('summary');
|
|
summary.className = 'fw-semibold small text-muted';
|
|
summary.textContent = gname;
|
|
details.appendChild(summary);
|
|
const gGrid = document.createElement('div');
|
|
gGrid.className = 'row g-2 mt-1';
|
|
details.appendChild(gGrid);
|
|
grid.appendChild(details);
|
|
fields.filter(f => f.group === gname).forEach(field => addField(field, undefined, gGrid));
|
|
});
|
|
};
|
|
|
|
const schemaKeys = new Set((plugin.fields || []).map(f => f.key));
|
|
const values = plugin.values || {};
|
|
// Keys managed by a dynamic editor on this same section (by prefix) are
|
|
// excluded from the raw "Other config values" list to avoid duplication.
|
|
const ownPrefixes = (plugin.dynamic_sections || [])
|
|
.filter(d => d.section === plugin.section && d.key_prefix)
|
|
.map(d => d.key_prefix.toLowerCase());
|
|
// Repeating blocks (e.g. mqtt1_*, mqtt2_*) are managed by their own editor.
|
|
const blockPatterns = (plugin.repeating_blocks || [])
|
|
.map(rb => new RegExp('^' + rb.id.toLowerCase() + '\\d+_'));
|
|
const extraKeys = Object.keys(values).filter(k =>
|
|
!schemaKeys.has(k)
|
|
&& !ownPrefixes.some(p => k.toLowerCase().startsWith(p))
|
|
&& !blockPatterns.some(rx => rx.test(k.toLowerCase())));
|
|
|
|
if (plugin.has_schema) {
|
|
plugin.fields.filter(f => !f.group).forEach(field => addField(field));
|
|
addGroupedFields(plugin.fields.filter(f => f.group));
|
|
if (extraKeys.length) {
|
|
const sub = document.createElement('div');
|
|
sub.className = 'col-12 text-muted small mt-1';
|
|
sub.textContent = 'Other config values';
|
|
grid.appendChild(sub);
|
|
extraKeys.forEach(key => addField({ key, label: key, type: 'str' }, values[key]));
|
|
}
|
|
} else if (extraKeys.length === 0) {
|
|
// Nothing beyond the on/off toggle in the header.
|
|
const note = document.createElement('p');
|
|
note.className = 'text-muted small fst-italic mb-0';
|
|
note.textContent = 'This command has no additional settings — use the on/off toggle above.';
|
|
form.insertBefore(note, grid);
|
|
} else {
|
|
const note = document.createElement('p');
|
|
note.className = 'text-muted small mb-2';
|
|
note.textContent = 'No typed schema for this plugin; editing raw values.';
|
|
form.insertBefore(note, grid);
|
|
extraKeys.forEach(key => addField({ key, label: key, type: 'str' }, values[key]));
|
|
}
|
|
|
|
// Dynamic key/value section editors (add/edit/delete rows).
|
|
const dynamicEditors = [];
|
|
(plugin.dynamic_sections || []).forEach(ds => {
|
|
const editor = buildDynamicEditor(ds);
|
|
form.appendChild(editor.el);
|
|
dynamicEditors.push(editor);
|
|
});
|
|
|
|
// Repeating structured-block editors (e.g. PacketCapture MQTT brokers).
|
|
const blockEditors = [];
|
|
(plugin.repeating_blocks || []).forEach(rb => {
|
|
const editor = buildRepeatingBlockEditor(rb);
|
|
form.appendChild(editor.el);
|
|
blockEditors.push(editor);
|
|
});
|
|
|
|
// Service restart caveat.
|
|
if (plugin.kind === 'service') {
|
|
const restart = document.createElement('div');
|
|
restart.className = 'restart-note mt-3';
|
|
restart.innerHTML = '<i class="fas fa-info-circle me-1"></i>' +
|
|
'Turning a service on or off takes effect after the next bot restart. ' +
|
|
'Setting changes apply on reload.';
|
|
form.appendChild(restart);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'd-flex align-items-center mt-3';
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.type = 'button';
|
|
saveBtn.className = 'btn btn-primary btn-sm';
|
|
saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
|
|
const status = document.createElement('span');
|
|
status.className = 'plugin-save-status';
|
|
actions.appendChild(saveBtn);
|
|
actions.appendChild(status);
|
|
form.appendChild(actions);
|
|
|
|
inner.appendChild(form);
|
|
body.appendChild(inner);
|
|
card.appendChild(body);
|
|
|
|
const toggle = header.querySelector('#' + CSS.escape(toggleId));
|
|
const collapse = new bootstrap.Collapse(body, { toggle: false });
|
|
|
|
// Header click expands/collapses; clicking the enable switch must not.
|
|
header.addEventListener('click', (e) => {
|
|
if (e.target.closest('.form-switch')) return;
|
|
const isOpen = card.classList.toggle('expanded');
|
|
if (isOpen) collapse.show(); else collapse.hide();
|
|
});
|
|
// The enable switch only changes enabled state + dim style.
|
|
toggle.addEventListener('change', () => {
|
|
card.classList.toggle('disabled', !toggle.checked);
|
|
});
|
|
|
|
saveBtn.addEventListener('click', () =>
|
|
this.save(plugin, toggle, inputs, saveBtn, status, dynamicEditors, blockEditors));
|
|
|
|
const searchText = `${plugin.label} ${plugin.section} ${plugin.description || ''}`.toLowerCase();
|
|
this.cards.push({ el: card, collapse, kind: plugin.kind, enabled: plugin.enabled, searchText,
|
|
setEnabled: (v) => { card.classList.toggle('disabled', !v); } });
|
|
|
|
return card;
|
|
}
|
|
|
|
async save(plugin, toggle, inputs, saveBtn, status, dynamicEditors = [], blockEditors = []) {
|
|
// Validate + collect only fields that changed from their loaded value.
|
|
const values = {};
|
|
let firstError = null;
|
|
let changedCount = 0;
|
|
inputs.forEach(({ field, input, original }) => {
|
|
clearInvalid(input);
|
|
const raw = readInput(field, input);
|
|
if (String(raw) === String(original)) return; // unchanged → skip
|
|
changedCount++;
|
|
const err = validateClient(field, raw);
|
|
if (err) {
|
|
markInvalid(input, err);
|
|
if (!firstError) firstError = err;
|
|
}
|
|
values[field.key] = raw;
|
|
});
|
|
|
|
// Dynamic sections: collect rows, validate keys, detect changes.
|
|
const dynamicPayload = {};
|
|
dynamicEditors.forEach(ed => {
|
|
const rows = ed.getRows();
|
|
const err = validateDynamicRows(rows);
|
|
if (err && !firstError) firstError = err;
|
|
if (JSON.stringify(rows) !== ed.original) changedCount++;
|
|
dynamicPayload[ed.section] = rows;
|
|
});
|
|
|
|
// Repeating blocks: collect blocks, detect changes.
|
|
const blocksPayload = {};
|
|
blockEditors.forEach(ed => {
|
|
const blocks = ed.getBlocks();
|
|
if (JSON.stringify(blocks) !== ed.original) changedCount++;
|
|
blocksPayload[ed.id] = blocks;
|
|
});
|
|
|
|
if (firstError) {
|
|
this.setStatus(status, firstError, 'danger');
|
|
return;
|
|
}
|
|
const enableChanged = (toggle.checked !== plugin.enabled);
|
|
if (changedCount === 0 && !enableChanged) {
|
|
this.setStatus(status, 'No changes to save.', 'muted');
|
|
return;
|
|
}
|
|
|
|
saveBtn.disabled = true;
|
|
const original = saveBtn.innerHTML;
|
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
|
this.setStatus(status, '', '');
|
|
try {
|
|
const resp = await fetch(`/api/plugins/${plugin.kind}/${plugin.name}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({
|
|
section: plugin.section,
|
|
enabled: toggle.checked,
|
|
values: values,
|
|
dynamic_sections: dynamicPayload,
|
|
repeating_blocks: blocksPayload,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) {
|
|
if (data.errors) {
|
|
inputs.forEach(({ field, input }) => {
|
|
if (data.errors[field.key]) markInvalid(input, data.errors[field.key]);
|
|
});
|
|
throw new Error('Please fix the highlighted fields');
|
|
}
|
|
throw new Error(data.error || 'Save failed');
|
|
}
|
|
let msg = 'Saved.';
|
|
if (data.restart_required) msg += ' Restart the bot to start/stop this service.';
|
|
else if (data.reload_queued) msg += ' Bot will reload shortly.';
|
|
this.setStatus(status, msg, 'success');
|
|
// Keep filter state (enabled-only) accurate.
|
|
plugin.enabled = toggle.checked;
|
|
const entry = this.cards.find(c => c.el.contains(saveBtn));
|
|
if (entry) entry.enabled = toggle.checked;
|
|
// Reset baselines so an immediate re-save is a no-op.
|
|
inputs.forEach(io => { io.original = readInput(io.field, io.input); });
|
|
dynamicEditors.forEach(ed => { ed.original = JSON.stringify(ed.getRows()); });
|
|
blockEditors.forEach(ed => { ed.original = JSON.stringify(ed.getBlocks()); });
|
|
} catch (err) {
|
|
this.setStatus(status, err.message, 'danger');
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = original;
|
|
}
|
|
}
|
|
|
|
setStatus(el, msg, type) {
|
|
el.textContent = msg;
|
|
el.className = 'plugin-save-status' + (type ? ' text-' + type : '');
|
|
if (type === 'success') {
|
|
setTimeout(() => { el.textContent = ''; el.className = 'plugin-save-status'; }, 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Field rendering & validation helpers -------------------------------
|
|
|
|
function renderField(field, overrideValue) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'mb-1';
|
|
const value = overrideValue !== undefined ? overrideValue : field.value;
|
|
|
|
let input;
|
|
if (field.type === 'bool') {
|
|
// Label on top (like every other field) so the switch lines up with
|
|
// adjacent inputs when fields share a row.
|
|
const uid = 'sw-' + Math.random().toString(36).slice(2, 9);
|
|
const label = document.createElement('label');
|
|
label.className = 'form-label d-block';
|
|
label.htmlFor = uid;
|
|
label.textContent = field.label;
|
|
if (field.section) label.appendChild(sectionBadge(field.section));
|
|
wrapper.appendChild(label);
|
|
const sw = document.createElement('div');
|
|
sw.className = 'form-check form-switch mb-0';
|
|
input = document.createElement('input');
|
|
input.className = 'form-check-input';
|
|
input.type = 'checkbox';
|
|
input.role = 'switch';
|
|
input.id = uid;
|
|
input.checked = (value === true || value === 'true');
|
|
sw.appendChild(input);
|
|
wrapper.appendChild(sw);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
}
|
|
|
|
const label = document.createElement('label');
|
|
label.className = 'form-label';
|
|
label.textContent = field.label + (field.required ? ' *' : '');
|
|
if (field.section) label.appendChild(sectionBadge(field.section));
|
|
wrapper.appendChild(label);
|
|
|
|
if (field.type === 'enum') {
|
|
input = document.createElement('select');
|
|
input.className = 'form-select form-select-sm';
|
|
(field.options || []).forEach(opt => {
|
|
const o = document.createElement('option');
|
|
o.value = opt.value;
|
|
o.textContent = opt.label || opt.value;
|
|
if (String(value) === String(opt.value)) o.selected = true;
|
|
input.appendChild(o);
|
|
});
|
|
wrapper.appendChild(input);
|
|
} else if (field.type === 'int' || field.type === 'float') {
|
|
const group = document.createElement('div');
|
|
group.className = 'input-group input-group-sm has-validation';
|
|
input = document.createElement('input');
|
|
input.className = 'form-control';
|
|
input.type = 'number';
|
|
if (field.min !== undefined && field.min !== null) input.min = field.min;
|
|
if (field.max !== undefined && field.max !== null) input.max = field.max;
|
|
input.step = field.type === 'float' ? 'any' : '1';
|
|
input.value = (value !== undefined && value !== null) ? value : '';
|
|
group.appendChild(input);
|
|
if (field.unit) {
|
|
const unit = document.createElement('span');
|
|
unit.className = 'input-group-text';
|
|
unit.textContent = field.unit;
|
|
group.appendChild(unit);
|
|
}
|
|
const fb = document.createElement('div');
|
|
fb.className = 'invalid-feedback';
|
|
group.appendChild(fb);
|
|
wrapper.appendChild(group);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.className = 'form-control form-control-sm';
|
|
input.type = 'text';
|
|
if (field.pattern) input.pattern = field.pattern;
|
|
input.value = (value !== undefined && value !== null)
|
|
? (Array.isArray(value) ? value.join(', ') : value) : '';
|
|
if (field.type === 'list') input.placeholder = 'comma, separated, values';
|
|
wrapper.appendChild(input);
|
|
}
|
|
|
|
const feedback = document.createElement('div');
|
|
feedback.className = 'invalid-feedback';
|
|
wrapper.appendChild(feedback);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
}
|
|
|
|
function helpText(text) {
|
|
const el = document.createElement('div');
|
|
el.className = 'form-text';
|
|
el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
// Choose a responsive grid width for a field so short controls pack densely.
|
|
// A field may override with "width": sm | md | lg | full.
|
|
function fieldColClass(field) {
|
|
switch (field.width) {
|
|
case 'full': return 'col-12';
|
|
case 'lg': return 'col-12 col-lg-8';
|
|
case 'md': return 'col-12 col-sm-6 col-xl-4';
|
|
case 'sm': return 'col-6 col-md-4 col-xl-3';
|
|
}
|
|
if (field.type === 'bool') return 'col-12 col-sm-6 col-lg-4';
|
|
if (field.type === 'int' || field.type === 'float' || field.type === 'enum') {
|
|
return 'col-6 col-md-4 col-xl-3';
|
|
}
|
|
return 'col-12 col-sm-6 col-xl-4'; // str / list — medium
|
|
}
|
|
|
|
// Badge marking a field that reads/writes a shared section (not the plugin's own).
|
|
function sectionBadge(section) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge section-badge ms-2';
|
|
b.title = 'Shared setting in [' + section + '] — changing it affects all commands that use it';
|
|
b.textContent = '[' + section + ']';
|
|
return b;
|
|
}
|
|
|
|
// --- Dynamic key/value section editor (add/edit/delete rows) -------------
|
|
|
|
function buildDynamicEditor(ds) {
|
|
const el = document.createElement('div');
|
|
el.className = 'dynamic-section mt-3';
|
|
|
|
const head = document.createElement('div');
|
|
head.innerHTML = `<div class="fw-semibold small mb-1">${escapeHtml(ds.label)}</div>`;
|
|
if (ds.help) head.appendChild(helpText(ds.help));
|
|
el.appendChild(head);
|
|
|
|
// Column headers
|
|
const colhead = document.createElement('div');
|
|
colhead.className = 'row g-2 text-muted small mt-1 d-none d-md-flex';
|
|
colhead.innerHTML =
|
|
`<div class="col-md-5">${escapeHtml(ds.key_label)}</div>` +
|
|
`<div class="col-md-6">${escapeHtml(ds.value_label)}</div>`;
|
|
el.appendChild(colhead);
|
|
|
|
const rowsContainer = document.createElement('div');
|
|
rowsContainer.className = 'dynamic-rows';
|
|
el.appendChild(rowsContainer);
|
|
|
|
const addRow = (key = '', value = '') => {
|
|
const row = document.createElement('div');
|
|
row.className = 'row g-2 align-items-center mb-1 dynamic-row';
|
|
const kc = document.createElement('div'); kc.className = 'col-12 col-md-5';
|
|
const ki = document.createElement('input');
|
|
ki.type = 'text'; ki.className = 'form-control form-control-sm dyn-key';
|
|
ki.placeholder = ds.key_placeholder || ''; ki.value = key;
|
|
kc.appendChild(ki);
|
|
const vc = document.createElement('div'); vc.className = 'col-10 col-md-6';
|
|
const vi = document.createElement('input');
|
|
vi.type = 'text'; vi.className = 'form-control form-control-sm dyn-value';
|
|
vi.placeholder = ds.value_placeholder || ''; vi.value = value;
|
|
vc.appendChild(vi);
|
|
const bc = document.createElement('div'); bc.className = 'col-2 col-md-1 text-end';
|
|
const rm = document.createElement('button');
|
|
rm.type = 'button'; rm.className = 'btn btn-outline-danger btn-sm';
|
|
rm.title = 'Remove'; rm.innerHTML = '<i class="fas fa-trash"></i>';
|
|
rm.addEventListener('click', () => row.remove());
|
|
bc.appendChild(rm);
|
|
row.appendChild(kc); row.appendChild(vc); row.appendChild(bc);
|
|
rowsContainer.appendChild(row);
|
|
return row;
|
|
};
|
|
|
|
(ds.items || []).forEach(it => addRow(it.key, it.value));
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.type = 'button';
|
|
addBtn.className = 'btn btn-outline-secondary btn-sm mt-1';
|
|
addBtn.innerHTML = '<i class="fas fa-plus me-1"></i>Add';
|
|
addBtn.addEventListener('click', () => {
|
|
const row = addRow('', '');
|
|
row.querySelector('.dyn-key').focus();
|
|
});
|
|
el.appendChild(addBtn);
|
|
|
|
const getRows = () => {
|
|
const out = [];
|
|
rowsContainer.querySelectorAll('.dynamic-row').forEach(r => {
|
|
const key = r.querySelector('.dyn-key').value.trim();
|
|
const value = r.querySelector('.dyn-value').value;
|
|
if (key === '' && value.trim() === '') return; // skip blank rows
|
|
out.push({ key, value });
|
|
});
|
|
return out;
|
|
};
|
|
|
|
// Snapshot via getRows() so change-detection matches the save serialization.
|
|
return { section: ds.section, el, getRows, original: JSON.stringify(getRows()) };
|
|
}
|
|
|
|
function validateDynamicRows(rows) {
|
|
const seen = new Set();
|
|
for (const { key } of rows) {
|
|
if (key === '') return 'A list row is missing its key';
|
|
if (/[=:\[\]\n\r]/.test(key)) return `Invalid key "${key}": cannot contain = : [ ] or newlines`;
|
|
if (key[0] === '#' || key[0] === ';') return `Invalid key "${key}": cannot start with # or ;`;
|
|
if (seen.has(key.toLowerCase())) return `Duplicate key "${key}"`;
|
|
seen.add(key.toLowerCase());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Repeating structured-block editor (add/remove numbered blocks) ------
|
|
|
|
function buildRepeatingBlockEditor(rb) {
|
|
const el = document.createElement('div');
|
|
el.className = 'repeating-section mt-3';
|
|
const head = document.createElement('div');
|
|
head.innerHTML = `<div class="fw-semibold small mb-1">${escapeHtml(rb.label)}</div>`;
|
|
if (rb.help) head.appendChild(helpText(rb.help));
|
|
el.appendChild(head);
|
|
|
|
const blocksContainer = document.createElement('div');
|
|
el.appendChild(blocksContainer);
|
|
const schemaKeys = new Set(rb.fields.map(f => f.key));
|
|
|
|
const renumber = () => {
|
|
blocksContainer.querySelectorAll('.rb-block').forEach((card, i) => {
|
|
const t = card.querySelector('.rb-block-title');
|
|
if (t) t.textContent = `${rb.item_label} ${i + 1}`;
|
|
});
|
|
};
|
|
|
|
const addBlock = (block) => {
|
|
const values = (block && block.values) || {};
|
|
// Preserve sub-keys not covered by the schema (e.g. jwt overrides).
|
|
const extras = {};
|
|
Object.keys(values).forEach(k => { if (!schemaKeys.has(k)) extras[k] = values[k]; });
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'card mb-2 rb-block';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'card-header d-flex align-items-center gap-2 py-2';
|
|
const swWrap = document.createElement('div');
|
|
swWrap.className = 'form-check form-switch mb-0';
|
|
const sw = document.createElement('input');
|
|
sw.className = 'form-check-input'; sw.type = 'checkbox'; sw.role = 'switch';
|
|
sw.checked = (block ? block.enabled !== false : true);
|
|
swWrap.appendChild(sw);
|
|
const title = document.createElement('span');
|
|
title.className = 'rb-block-title fw-semibold flex-grow-1';
|
|
const rm = document.createElement('button');
|
|
rm.type = 'button'; rm.className = 'btn btn-outline-danger btn-sm';
|
|
rm.title = 'Remove'; rm.innerHTML = '<i class="fas fa-trash"></i>';
|
|
rm.addEventListener('click', () => { card.remove(); renumber(); });
|
|
header.append(swWrap, title, rm);
|
|
card.appendChild(header);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'card-body py-2';
|
|
const grid = document.createElement('div');
|
|
grid.className = 'row g-2';
|
|
const inputs = [];
|
|
rb.fields.forEach(field => {
|
|
const col = document.createElement('div');
|
|
col.className = fieldColClass(field);
|
|
const { wrapper, input } = renderField(field, values[field.key]);
|
|
col.appendChild(wrapper);
|
|
grid.appendChild(col);
|
|
inputs.push({ field, input });
|
|
});
|
|
body.appendChild(grid);
|
|
card.appendChild(body);
|
|
|
|
// Dim the body when the block is disabled.
|
|
const applyDim = () => { body.style.opacity = sw.checked ? '' : '0.55'; };
|
|
sw.addEventListener('change', applyDim); applyDim();
|
|
|
|
card._rb = { sw, inputs, extras };
|
|
blocksContainer.appendChild(card);
|
|
renumber();
|
|
};
|
|
|
|
(rb.blocks || []).forEach(addBlock);
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.type = 'button';
|
|
addBtn.className = 'btn btn-outline-secondary btn-sm mt-1';
|
|
addBtn.innerHTML = `<i class="fas fa-plus me-1"></i>Add ${escapeHtml(rb.item_label)}`;
|
|
addBtn.addEventListener('click', () => addBlock({ enabled: true, values: {} }));
|
|
el.appendChild(addBtn);
|
|
|
|
const getBlocks = () => {
|
|
const out = [];
|
|
blocksContainer.querySelectorAll('.rb-block').forEach(card => {
|
|
const { sw, inputs, extras } = card._rb;
|
|
const values = { ...extras };
|
|
inputs.forEach(({ field, input }) => { values[field.key] = readInput(field, input); });
|
|
out.push({ enabled: sw.checked, values });
|
|
});
|
|
return out;
|
|
};
|
|
|
|
return { id: rb.id, el, getBlocks, original: JSON.stringify(getBlocks()) };
|
|
}
|
|
|
|
// Locate a field's wrapper so we can find its .invalid-feedback even when the
|
|
// input is nested inside an .input-group (number fields with a unit suffix).
|
|
function fieldWrapper(input) {
|
|
return input.closest('.input-group, .mb-1, .form-check') || input.parentElement;
|
|
}
|
|
|
|
function markInvalid(input, message) {
|
|
input.classList.add('is-invalid');
|
|
const fb = fieldWrapper(input).querySelector('.invalid-feedback');
|
|
if (fb) { fb.textContent = message; fb.style.display = 'block'; }
|
|
}
|
|
|
|
function clearInvalid(input) {
|
|
input.classList.remove('is-invalid');
|
|
const fb = fieldWrapper(input).querySelector('.invalid-feedback');
|
|
if (fb) { fb.textContent = ''; fb.style.display = ''; }
|
|
}
|
|
|
|
function readInput(field, input) {
|
|
if (field.type === 'bool') return input.checked;
|
|
return input.value.trim();
|
|
}
|
|
|
|
function validateClient(field, raw) {
|
|
const empty = (raw === '' || raw === null || raw === undefined);
|
|
if (field.type === 'bool') return null;
|
|
if (empty) {
|
|
return field.required ? `${field.label} is required` : null;
|
|
}
|
|
if (field.type === 'int' || field.type === 'float') {
|
|
const num = Number(raw);
|
|
if (Number.isNaN(num)) return `${field.label} must be a number`;
|
|
if (field.type === 'int' && !Number.isInteger(num)) return `${field.label} must be a whole number`;
|
|
if (field.min !== undefined && field.min !== null && num < field.min) return `${field.label} must be ≥ ${field.min}`;
|
|
if (field.max !== undefined && field.max !== null && num > field.max) return `${field.label} must be ≤ ${field.max}`;
|
|
}
|
|
if (field.type === 'enum') {
|
|
const allowed = (field.options || []).map(o => String(o.value));
|
|
if (!allowed.includes(String(raw))) return `${field.label} is invalid`;
|
|
}
|
|
if (field.type === 'str' && field.pattern) {
|
|
if (!new RegExp('^(?:' + field.pattern + ')$').test(raw)) return `${field.label} has an invalid format`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.pluginsManager = new PluginsManager();
|
|
window.pluginsManager.load();
|
|
});
|
|
</script>
|
|
{% endblock %}
|