541 lines
20 KiB
JavaScript
541 lines
20 KiB
JavaScript
/* vibedns management interface behaviour.
|
|
*
|
|
* Deliberately small and dependency-free beyond Bootstrap's own bundle: theme
|
|
* persistence, toast display, confirmation dialogs, bulk selection, the
|
|
* record-type editor, and a couple of small conveniences. There is no build
|
|
* step and nothing is fetched from the network.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// --- Theme ------------------------------------------------------------
|
|
|
|
var THEME_KEY = 'vibedns.theme';
|
|
|
|
function storedTheme() {
|
|
try { return localStorage.getItem(THEME_KEY); } catch (e) { return null; }
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
try { localStorage.setItem(THEME_KEY, theme); } catch (e) { /* private mode */ }
|
|
}
|
|
|
|
function initTheme() {
|
|
var saved = storedTheme();
|
|
if (!saved) {
|
|
saved = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
? 'dark' : 'light';
|
|
}
|
|
document.documentElement.setAttribute('data-bs-theme', saved);
|
|
|
|
var toggle = document.getElementById('themeToggle');
|
|
if (toggle) {
|
|
toggle.addEventListener('click', function () {
|
|
var current = document.documentElement.getAttribute('data-bs-theme');
|
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply before first paint to avoid a flash of the wrong theme.
|
|
(function () {
|
|
var saved = storedTheme();
|
|
if (saved) { document.documentElement.setAttribute('data-bs-theme', saved); }
|
|
})();
|
|
|
|
// --- Toasts -----------------------------------------------------------
|
|
|
|
function initToasts() {
|
|
document.querySelectorAll('#toastContainer .toast').forEach(function (el) {
|
|
var autohide = el.getAttribute('data-autohide') !== 'false';
|
|
new bootstrap.Toast(el, { autohide: autohide, delay: 6000 }).show();
|
|
});
|
|
}
|
|
|
|
/** Shows a transient message without a page reload. */
|
|
window.vibednsToast = function (level, message) {
|
|
// Successful actions already update the page, so avoid distracting green
|
|
// confirmation boxes. Warnings and errors remain visible.
|
|
if (level === 'success') { return; }
|
|
var container = document.getElementById('toastContainer');
|
|
if (!container) { return; }
|
|
var el = document.createElement('div');
|
|
el.className = 'toast align-items-center border-0 text-bg-' + level;
|
|
el.setAttribute('role', 'alert');
|
|
var body = document.createElement('div');
|
|
body.className = 'd-flex';
|
|
var text = document.createElement('div');
|
|
text.className = 'toast-body';
|
|
text.textContent = message;
|
|
var close = document.createElement('button');
|
|
close.type = 'button';
|
|
close.className = 'btn-close btn-close-white me-2 m-auto';
|
|
close.setAttribute('data-bs-dismiss', 'toast');
|
|
close.setAttribute('aria-label', 'Close');
|
|
body.appendChild(text);
|
|
body.appendChild(close);
|
|
el.appendChild(body);
|
|
container.appendChild(el);
|
|
new bootstrap.Toast(el, { delay: 5000 }).show();
|
|
el.addEventListener('hidden.bs.toast', function () { el.remove(); });
|
|
};
|
|
|
|
// --- Confirmation dialogs --------------------------------------------
|
|
|
|
function initConfirmations() {
|
|
var modalEl = document.getElementById('confirmModal');
|
|
if (!modalEl) { return; }
|
|
var modal = new bootstrap.Modal(modalEl);
|
|
var bodyEl = document.getElementById('confirmModalBody');
|
|
var acceptEl = document.getElementById('confirmModalAccept');
|
|
var pendingForm = null;
|
|
|
|
var pendingButton = null;
|
|
|
|
function ask(form, button, message) {
|
|
pendingForm = form;
|
|
pendingButton = button;
|
|
bodyEl.textContent = message || 'This action cannot be undone.';
|
|
acceptEl.textContent = (form && form.dataset.confirmLabel) || 'Confirm';
|
|
modal.show();
|
|
}
|
|
|
|
// A whole form marked js-confirm (single-action buttons such as Delete).
|
|
document.addEventListener('submit', function (ev) {
|
|
var form = ev.target;
|
|
if (!form.classList || !form.classList.contains('js-confirm')) { return; }
|
|
if (form.dataset.confirmed === 'true') { return; }
|
|
ev.preventDefault();
|
|
ask(form, null, form.dataset.confirm);
|
|
});
|
|
|
|
// An individual submit button inside a multi-action form (bulk toolbars),
|
|
// where only one of the buttons is destructive.
|
|
document.addEventListener('click', function (ev) {
|
|
var btn = ev.target.closest('button.js-confirm-bulk');
|
|
if (!btn || btn.dataset.confirmed === 'true') { return; }
|
|
var form = btn.form;
|
|
if (!form) { return; }
|
|
ev.preventDefault();
|
|
ask(form, btn, btn.dataset.confirm);
|
|
});
|
|
|
|
acceptEl.addEventListener('click', function () {
|
|
if (!pendingForm) { return; }
|
|
modal.hide();
|
|
if (pendingButton) {
|
|
pendingButton.dataset.confirmed = 'true';
|
|
// requestSubmit keeps the button's name/value in the submission, which
|
|
// is how the server knows which bulk action was chosen.
|
|
pendingForm.requestSubmit(pendingButton);
|
|
} else {
|
|
pendingForm.dataset.confirmed = 'true';
|
|
pendingForm.submit();
|
|
}
|
|
pendingForm = null;
|
|
pendingButton = null;
|
|
});
|
|
|
|
modalEl.addEventListener('hidden.bs.modal', function () {
|
|
pendingForm = null;
|
|
pendingButton = null;
|
|
});
|
|
}
|
|
|
|
// --- Filter forms -----------------------------------------------------
|
|
|
|
/** Submits a filter form shortly after the user stops typing. */
|
|
function initAutoFilters() {
|
|
document.querySelectorAll('form[data-autosubmit]').forEach(function (form) {
|
|
var timer = null;
|
|
form.querySelectorAll('input[type="search"], input[type="text"]').forEach(function (input) {
|
|
input.addEventListener('input', function () {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(function () { form.requestSubmit(); }, 400);
|
|
});
|
|
});
|
|
form.querySelectorAll('select').forEach(function (select) {
|
|
select.addEventListener('change', function () { form.requestSubmit(); });
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Bulk selection ---------------------------------------------------
|
|
|
|
function initBulkSelect() {
|
|
document.querySelectorAll('[data-bulk-scope]').forEach(function (scope) {
|
|
var master = scope.querySelector('[data-bulk-all]');
|
|
var boxes = function () { return scope.querySelectorAll('[data-bulk-item]'); };
|
|
var bar = scope.querySelector('[data-bulk-bar]');
|
|
var count = scope.querySelector('[data-bulk-count]');
|
|
|
|
function refresh() {
|
|
var selected = scope.querySelectorAll('[data-bulk-item]:checked').length;
|
|
if (bar) { bar.classList.toggle('is-visible', selected > 0); }
|
|
if (count) { count.textContent = String(selected); }
|
|
if (master) {
|
|
var total = boxes().length;
|
|
master.checked = total > 0 && selected === total;
|
|
master.indeterminate = selected > 0 && selected < total;
|
|
}
|
|
}
|
|
|
|
if (master) {
|
|
master.addEventListener('change', function () {
|
|
boxes().forEach(function (b) { b.checked = master.checked; });
|
|
refresh();
|
|
});
|
|
}
|
|
scope.addEventListener('change', function (ev) {
|
|
if (ev.target.matches('[data-bulk-item]')) { refresh(); }
|
|
});
|
|
refresh();
|
|
});
|
|
}
|
|
|
|
// --- Copy to clipboard ------------------------------------------------
|
|
|
|
function initCopyButtons() {
|
|
document.addEventListener('click', function (ev) {
|
|
var btn = ev.target.closest('[data-copy]');
|
|
if (!btn) { return; }
|
|
ev.preventDefault();
|
|
var text = btn.getAttribute('data-copy');
|
|
var target = btn.getAttribute('data-copy-target');
|
|
if (target) {
|
|
var el = document.querySelector(target);
|
|
if (el) { text = el.textContent.trim(); }
|
|
}
|
|
if (!text) { return; }
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
window.vibednsToast('success', 'Copied to clipboard.');
|
|
}, function () {
|
|
window.vibednsToast('warning', 'The browser refused clipboard access.');
|
|
});
|
|
} else {
|
|
window.vibednsToast('warning', 'Clipboard access needs a secure (HTTPS) connection.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Reverse zone preview --------------------------------------------
|
|
|
|
/** Shows which reverse zone a subnet will produce, before submitting. */
|
|
function initReversePreview() {
|
|
var input = document.getElementById('reverseCidr');
|
|
var out = document.getElementById('reversePreview');
|
|
if (!input || !out) { return; }
|
|
|
|
var timer = null;
|
|
function update() {
|
|
var value = input.value.trim();
|
|
if (!value) { out.textContent = ''; return; }
|
|
fetch('/api/v1/tools/reverse-zone?cidr=' + encodeURIComponent(value), {
|
|
headers: { 'Accept': 'application/json' }
|
|
}).then(function (r) { return r.json(); }).then(function (data) {
|
|
if (data.error) {
|
|
out.className = 'form-text text-danger';
|
|
out.textContent = data.error.message || 'That is not a valid subnet.';
|
|
return;
|
|
}
|
|
out.className = 'form-text text-success';
|
|
out.textContent = 'Zone: ' + data.zone + (data.note ? ' — ' + data.note : '');
|
|
}).catch(function () {
|
|
out.textContent = '';
|
|
});
|
|
}
|
|
|
|
input.addEventListener('input', function () {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(update, 300);
|
|
});
|
|
if (input.value) { update(); }
|
|
}
|
|
|
|
// --- Page data --------------------------------------------------------
|
|
|
|
/* Page data is delivered in data- attributes rather than inline <script>
|
|
* blocks, because the Content-Security-Policy only allows scripts served
|
|
* from this origin. */
|
|
function readData(selector, attr) {
|
|
var el = document.querySelector(selector);
|
|
if (!el) { return null; }
|
|
var raw = el.getAttribute(attr);
|
|
if (!raw) { return null; }
|
|
try { return JSON.parse(raw); } catch (e) { return null; }
|
|
}
|
|
|
|
// --- Record editor ----------------------------------------------------
|
|
|
|
/* The record form is generated from the type catalogue the server embeds in
|
|
* the page, so every supported type gets a proper labelled editor without a
|
|
* hand-written form per type. */
|
|
function initRecordEditor() {
|
|
var root = document.getElementById('recordEditor');
|
|
if (!root) { return; }
|
|
|
|
var catalogue = readData('#recordEditor', 'data-types');
|
|
var values = readData('#recordEditor', 'data-values') || {};
|
|
if (!catalogue) { return; }
|
|
|
|
var typeSelect = root.querySelector('[data-record-type]');
|
|
var fieldsEl = root.querySelector('[data-record-fields]');
|
|
var rawWrap = root.querySelector('[data-record-raw]');
|
|
var rawInput = rawWrap ? rawWrap.querySelector('textarea, input') : null;
|
|
var advancedToggle = root.querySelector('[data-record-advanced]');
|
|
if (!typeSelect || !fieldsEl) { return; }
|
|
|
|
function infoFor(type) {
|
|
for (var i = 0; i < catalogue.length; i++) {
|
|
if (catalogue[i].type === type) { return catalogue[i]; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildField(field, value) {
|
|
var col = document.createElement('div');
|
|
col.className = 'col-12 col-md-' + (field.width || 12);
|
|
|
|
var label = document.createElement('label');
|
|
label.className = 'form-label';
|
|
label.textContent = field.label;
|
|
label.setAttribute('for', 'field_' + field.key);
|
|
col.appendChild(label);
|
|
|
|
var input;
|
|
if (field.type === 'textarea') {
|
|
input = document.createElement('textarea');
|
|
input.rows = 3;
|
|
} else if (field.type === 'select') {
|
|
input = document.createElement('select');
|
|
(field.options || []).forEach(function (opt) {
|
|
var o = document.createElement('option');
|
|
o.value = opt;
|
|
o.textContent = opt;
|
|
if (opt === value) { o.selected = true; }
|
|
input.appendChild(o);
|
|
});
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.type = field.type === 'number' ? 'number' : 'text';
|
|
}
|
|
input.className = field.type === 'select' ? 'form-select' : 'form-control';
|
|
input.name = 'field_' + field.key;
|
|
input.id = 'field_' + field.key;
|
|
if (field.placeholder) { input.placeholder = field.placeholder; }
|
|
if (field.required) { input.required = true; }
|
|
if (input.tagName !== 'SELECT' && value != null) { input.value = value; }
|
|
col.appendChild(input);
|
|
|
|
if (field.help) {
|
|
var help = document.createElement('div');
|
|
help.className = 'form-text';
|
|
help.textContent = field.help;
|
|
col.appendChild(help);
|
|
}
|
|
return col;
|
|
}
|
|
|
|
function render() {
|
|
var type = typeSelect.value;
|
|
var advanced = advancedToggle && advancedToggle.checked;
|
|
var info = infoFor(type);
|
|
|
|
fieldsEl.innerHTML = '';
|
|
|
|
if (advanced || !info) {
|
|
fieldsEl.classList.add('d-none');
|
|
if (rawWrap) { rawWrap.classList.remove('d-none'); }
|
|
if (rawInput) { rawInput.disabled = false; }
|
|
return;
|
|
}
|
|
|
|
fieldsEl.classList.remove('d-none');
|
|
if (rawWrap) { rawWrap.classList.add('d-none'); }
|
|
if (rawInput) { rawInput.disabled = true; }
|
|
|
|
info.fields.forEach(function (field) {
|
|
fieldsEl.appendChild(buildField(field, values[field.key]));
|
|
});
|
|
}
|
|
|
|
typeSelect.addEventListener('change', function () {
|
|
// Values from the previously selected type no longer apply.
|
|
values = {};
|
|
render();
|
|
});
|
|
if (advancedToggle) { advancedToggle.addEventListener('change', render); }
|
|
render();
|
|
|
|
// One modal serves both "add" and "edit": the button that opened it
|
|
// carries the record in data- attributes, which are read here.
|
|
var modalEl = document.getElementById('recordModal');
|
|
if (!modalEl) { return; }
|
|
var form = document.getElementById('recordForm');
|
|
var titleEl = document.getElementById('recordModalLabel');
|
|
|
|
modalEl.addEventListener('show.bs.modal', function (ev) {
|
|
var trigger = ev.relatedTarget;
|
|
if (!trigger) { return; }
|
|
var d = trigger.dataset;
|
|
var isNew = d.recordNew !== undefined;
|
|
|
|
if (form && d.recordAction) { form.action = d.recordAction; }
|
|
if (titleEl) { titleEl.textContent = isNew ? 'Add record' : 'Edit record'; }
|
|
|
|
var nameEl = document.getElementById('recordName');
|
|
var ttlEl = document.getElementById('recordTTL');
|
|
var commentEl = document.getElementById('recordComment');
|
|
var enabledEl = document.getElementById('recordEnabled');
|
|
var rawEl = document.getElementById('recordData');
|
|
|
|
if (nameEl) { nameEl.value = isNew ? '@' : (d.recordName || '@'); }
|
|
if (ttlEl) { ttlEl.value = isNew ? '' : (d.recordTtl || ''); }
|
|
if (commentEl) { commentEl.value = isNew ? '' : (d.recordComment || ''); }
|
|
if (enabledEl) { enabledEl.checked = isNew ? true : d.recordEnabled === 'true'; }
|
|
if (rawEl) { rawEl.value = isNew ? '' : (d.recordData || ''); }
|
|
if (advancedToggle) { advancedToggle.checked = false; }
|
|
|
|
if (!isNew && d.recordRtype) { typeSelect.value = d.recordRtype; }
|
|
if (isNew) { typeSelect.value = 'A'; }
|
|
|
|
values = {};
|
|
if (!isNew && d.recordValues) {
|
|
try { values = JSON.parse(d.recordValues) || {}; } catch (e) { values = {}; }
|
|
}
|
|
render();
|
|
});
|
|
}
|
|
|
|
// --- Assorted behaviour ----------------------------------------------
|
|
|
|
/* The Content-Security-Policy forbids inline handlers, so anything that
|
|
* would normally be an onclick attribute is delegated from here. */
|
|
function initDelegatedActions() {
|
|
document.addEventListener('click', function (ev) {
|
|
if (ev.target.closest('[data-history-back]')) {
|
|
ev.preventDefault();
|
|
history.back();
|
|
}
|
|
});
|
|
|
|
// The restore modal is shared by every backup row; the row that opened it
|
|
// supplies the file name.
|
|
var restoreModal = document.getElementById('restoreModal');
|
|
if (restoreModal) {
|
|
restoreModal.addEventListener('show.bs.modal', function (ev) {
|
|
var trigger = ev.relatedTarget;
|
|
if (!trigger) { return; }
|
|
var name = trigger.getAttribute('data-backup-name') || '';
|
|
var form = document.getElementById('restoreForm');
|
|
var nameEl = document.getElementById('restoreName');
|
|
var confirmEl = document.getElementById('restoreConfirm');
|
|
if (form) {
|
|
form.action = '/settings/database/backup/' + encodeURIComponent(name) + '/restore';
|
|
}
|
|
if (nameEl) { nameEl.textContent = name; }
|
|
if (confirmEl) { confirmEl.value = ''; confirmEl.placeholder = name; }
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Charts -----------------------------------------------------------
|
|
|
|
function chartColours() {
|
|
var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
|
return {
|
|
grid: dark ? 'rgba(255,255,255,.08)' : 'rgba(16,24,40,.08)',
|
|
text: dark ? '#adb5bd' : '#6c757d',
|
|
series: ['#1b6ec2', '#d63939', '#2fb344', '#f59f00', '#7048e8', '#0ca678']
|
|
};
|
|
}
|
|
|
|
function initCharts() {
|
|
if (typeof Chart === 'undefined') { return; }
|
|
var c = chartColours();
|
|
Chart.defaults.color = c.text;
|
|
Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
|
|
Chart.defaults.animation = false;
|
|
|
|
var activity = readData('#chartData', 'data-activity');
|
|
var types = readData('#chartData', 'data-types');
|
|
|
|
var activityEl = document.getElementById('activityChart');
|
|
if (activityEl && activity) {
|
|
var labels = activity.map(function (b) {
|
|
var d = new Date(b.start);
|
|
return d.getHours().toString().padStart(2, '0') + ':' +
|
|
d.getMinutes().toString().padStart(2, '0');
|
|
});
|
|
new Chart(activityEl, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Total', data: activity.map(function (b) { return b.total; }),
|
|
borderColor: c.series[0], backgroundColor: 'rgba(27,110,194,.12)',
|
|
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
|
|
},
|
|
{
|
|
label: 'Blocked', data: activity.map(function (b) { return b.blocked; }),
|
|
borderColor: c.series[1], backgroundColor: 'rgba(214,57,57,.12)',
|
|
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
|
|
},
|
|
{
|
|
label: 'Cached', data: activity.map(function (b) { return b.cached; }),
|
|
borderColor: c.series[2], backgroundColor: 'rgba(47,179,68,.10)',
|
|
fill: true, tension: .3, pointRadius: 0, borderWidth: 2
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, usePointStyle: true } } },
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { maxTicksLimit: 12 } },
|
|
y: { beginAtZero: true, grid: { color: c.grid }, ticks: { precision: 0 } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
var typeEl = document.getElementById('typeChart');
|
|
if (typeEl && types && types.length) {
|
|
new Chart(typeEl, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: types.map(function (t) { return t.label; }),
|
|
datasets: [{
|
|
data: types.map(function (t) { return t.value; }),
|
|
backgroundColor: c.series,
|
|
borderWidth: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false, cutout: '62%',
|
|
plugins: { legend: { position: 'right', labels: { boxWidth: 12, usePointStyle: true } } }
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Boot -------------------------------------------------------------
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
initTheme();
|
|
initToasts();
|
|
initConfirmations();
|
|
initAutoFilters();
|
|
initBulkSelect();
|
|
initCopyButtons();
|
|
initReversePreview();
|
|
initRecordEditor();
|
|
initDelegatedActions();
|
|
initCharts();
|
|
});
|
|
})();
|