initial commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
/* 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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user