initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+540
View File
@@ -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();
});
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long