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
+361
View File
@@ -0,0 +1,361 @@
/* vibedns management interface
*
* A thin layer on top of Bootstrap 5: an app shell with a fixed sidebar,
* a small set of component styles, and colour tokens that follow Bootstrap's
* own light/dark switch so the whole UI flips with one attribute.
*/
:root {
--app-sidebar-width: 248px;
--app-navbar-height: 56px;
--app-brand: #1b6ec2;
--app-brand-dark: #14508c;
--app-sidebar-bg: #ffffff;
--app-sidebar-border: var(--bs-border-color);
--app-sidebar-link: #495057;
--app-sidebar-link-hover-bg: #f1f3f5;
--app-sidebar-active-bg: #e7f1fb;
--app-sidebar-active-color: var(--app-brand-dark);
--app-body-bg: #f6f8fa;
--app-card-shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 1px 3px rgba(16, 24, 40, .08);
}
[data-bs-theme="dark"] {
--app-brand: #4c9be8;
--app-brand-dark: #7cb8f0;
--app-sidebar-bg: #17191d;
--app-sidebar-border: #2b2f36;
--app-sidebar-link: #adb5bd;
--app-sidebar-link-hover-bg: #22252b;
--app-sidebar-active-bg: #1d2b3a;
--app-sidebar-active-color: #8fc3f5;
--app-body-bg: #101214;
--app-card-shadow: 0 1px 2px rgba(0, 0, 0, .4);
}
body {
background-color: var(--app-body-bg);
font-size: .9375rem;
}
.skip-link {
position: absolute;
top: .5rem;
left: .5rem;
z-index: 2000;
background: var(--bs-body-bg);
padding: .5rem .75rem;
border-radius: .375rem;
}
/* --- Shell ------------------------------------------------------------ */
.app-navbar {
background: linear-gradient(90deg, var(--app-brand-dark), var(--app-brand));
min-height: var(--app-navbar-height);
box-shadow: 0 1px 3px rgba(16, 24, 40, .15);
z-index: 1030;
}
.app-navbar .navbar-brand {
color: #fff;
font-weight: 600;
letter-spacing: .01em;
}
.app-navbar .navbar-brand:hover { color: #fff; }
.status-dot {
width: .55rem;
height: .55rem;
border-radius: 50%;
background: #adb5bd;
display: inline-block;
}
.status-dot.status-up {
background: #2fb344;
box-shadow: 0 0 0 3px rgba(47, 179, 68, .25);
}
.status-dot.status-down {
background: #d63939;
box-shadow: 0 0 0 3px rgba(214, 57, 57, .25);
}
.app-shell { display: flex; align-items: flex-start; }
.app-sidebar {
width: var(--app-sidebar-width);
flex: 0 0 var(--app-sidebar-width);
position: sticky;
top: var(--app-navbar-height);
height: calc(100vh - var(--app-navbar-height));
overflow-y: auto;
background: var(--app-sidebar-bg);
border-right: 1px solid var(--app-sidebar-border);
}
.app-main { flex: 1 1 auto; min-width: 0; }
.app-offcanvas { width: var(--app-sidebar-width); }
.sidebar-nav { padding: .75rem .5rem 2rem; }
.sidebar-heading {
font-size: .6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--bs-secondary-color);
padding: 1rem .75rem .35rem;
}
.sidebar-link {
display: flex;
align-items: center;
gap: .625rem;
padding: .5rem .75rem;
margin-bottom: .0625rem;
border-radius: .375rem;
color: var(--app-sidebar-link);
text-decoration: none;
font-weight: 500;
transition: background-color .12s ease, color .12s ease;
}
.sidebar-link i { font-size: 1rem; width: 1.25rem; text-align: center; flex: none; }
.sidebar-link:hover {
background: var(--app-sidebar-link-hover-bg);
color: var(--bs-body-color);
}
.sidebar-link.active {
background: var(--app-sidebar-active-bg);
color: var(--app-sidebar-active-color);
font-weight: 600;
}
/* --- Typography and cards --------------------------------------------- */
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: .125rem;
}
.card {
border-color: var(--bs-border-color);
box-shadow: var(--app-card-shadow);
}
.card-header {
background: transparent;
border-bottom-color: var(--bs-border-color);
font-weight: 600;
padding-block: .75rem;
}
.stat-card .card-body { padding: 1rem 1.125rem; }
.stat-label {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.2;
margin-top: .25rem;
font-variant-numeric: tabular-nums;
}
.stat-sub { font-size: .8125rem; color: var(--bs-secondary-color); margin-top: .125rem; }
.stat-icon { font-size: 1.125rem; color: var(--bs-secondary-color); opacity: .75; }
/* --- Tables ------------------------------------------------------------ */
.table { --bs-table-bg: transparent; margin-bottom: 0; }
.table > thead > tr > th {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
border-bottom-width: 1px;
white-space: nowrap;
}
.table > tbody > tr > td { vertical-align: middle; }
.table-hover > tbody > tr:hover > * { --bs-table-accent-bg: var(--app-sidebar-link-hover-bg); }
.table-compact > :not(caption) > * > * { padding: .5rem .625rem; }
.mono {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
}
.truncate-cell {
max-width: 26rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions { white-space: nowrap; text-align: right; }
.row-actions .btn { --bs-btn-padding-y: .1875rem; --bs-btn-padding-x: .4375rem; }
/* Disabled rows read as inactive without disappearing. */
tr.is-disabled > td:not(.row-actions) { opacity: .55; }
/* --- Record type chips ------------------------------------------------- */
.type-chip {
display: inline-block;
min-width: 3.75rem;
text-align: center;
padding: .1875rem .4375rem;
border-radius: .25rem;
font-size: .75rem;
font-weight: 600;
font-family: var(--bs-font-monospace);
border: 1px solid transparent;
}
.type-addr { background: #e7f1fb; color: #14508c; border-color: #c9e0f7; }
.type-alias { background: #f3e8fd; color: #6b21a8; border-color: #e4d0fb; }
.type-service { background: #e6f7f1; color: #0f6b52; border-color: #c5ecdf; }
.type-auth { background: #fef3e2; color: #92500e; border-color: #fbe0b8; }
.type-text { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
.type-sec { background: #fde8ec; color: #9b1c33; border-color: #f8ccd6; }
.type-ptr { background: #e8f4fd; color: #0b5d7a; border-color: #cbe6f6; }
.type-other { background: #eef0f2; color: #41474d; border-color: #dde1e5; }
[data-bs-theme="dark"] .type-addr { background: #12283d; color: #8fc3f5; border-color: #1d3d5c; }
[data-bs-theme="dark"] .type-alias { background: #2a1a3d; color: #cba6f7; border-color: #3d2757; }
[data-bs-theme="dark"] .type-service { background: #12302a; color: #7ad4b3; border-color: #1c4a3f; }
[data-bs-theme="dark"] .type-auth { background: #362508; color: #f0c674; border-color: #4d360f; }
[data-bs-theme="dark"] .type-text { background: #22252b; color: #adb5bd; border-color: #32363d; }
[data-bs-theme="dark"] .type-sec { background: #3a151f; color: #f2a1b2; border-color: #55202e; }
[data-bs-theme="dark"] .type-ptr { background: #102b36; color: #86cfe8; border-color: #1a4252; }
[data-bs-theme="dark"] .type-other { background: #22252b; color: #adb5bd; border-color: #32363d; }
/* --- Empty states ------------------------------------------------------ */
.empty-state { color: var(--bs-secondary-color); }
.empty-icon {
font-size: 2.75rem;
color: var(--bs-secondary-color);
opacity: .45;
}
/* --- Forms ------------------------------------------------------------- */
.form-label { font-weight: 500; margin-bottom: .3125rem; }
.form-text { font-size: .8125rem; }
.settings-nav .nav-link {
--bs-nav-pills-border-radius: .375rem;
padding: .375rem .75rem;
font-size: .875rem;
font-weight: 500;
color: var(--bs-body-color);
}
.settings-nav .nav-link:not(.active):hover { background: var(--app-sidebar-link-hover-bg); }
.form-section + .form-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--bs-border-color); }
.form-section-title { font-size: .9375rem; font-weight: 600; margin-bottom: .875rem; }
textarea.list-input {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
min-height: 7.5rem;
}
/* Sticky action bar for long settings forms. */
.form-actions {
position: sticky;
bottom: 0;
background: var(--bs-body-bg);
border-top: 1px solid var(--bs-border-color);
padding: .875rem 1.125rem;
margin: 1.5rem -1.125rem -1.125rem;
border-radius: 0 0 var(--bs-card-border-radius) var(--bs-card-border-radius);
}
/* --- Charts ------------------------------------------------------------ */
.chart-wrap { position: relative; height: 260px; }
.chart-wrap-sm { position: relative; height: 200px; }
/* --- Misc -------------------------------------------------------------- */
.bulk-bar {
display: none;
align-items: center;
gap: .75rem;
padding: .625rem .875rem;
background: var(--app-sidebar-active-bg);
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
margin-bottom: .75rem;
}
.bulk-bar.is-visible { display: flex; }
.token-secret {
font-family: var(--bs-font-monospace);
font-size: .875rem;
word-break: break-all;
background: var(--bs-tertiary-bg);
padding: .625rem .75rem;
border-radius: .375rem;
border: 1px solid var(--bs-border-color);
}
.answer-block {
font-family: var(--bs-font-monospace);
font-size: .8125rem;
white-space: pre-wrap;
word-break: break-all;
margin-bottom: 0;
}
.progress-thin { height: .375rem; }
.list-meta { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.list-meta > div { min-width: 7rem; }
.list-meta dt {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--bs-secondary-color);
font-weight: 600;
}
.list-meta dd { font-size: 1.125rem; font-weight: 600; margin-bottom: 0; }
@media (max-width: 991.98px) {
.app-sidebar { display: none; }
}
@media print {
.app-sidebar, .app-navbar, .toast-container, .row-actions { display: none !important; }
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="VibeDNS">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2a86d8"/>
<stop offset="100%" stop-color="#14508c"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="url(#g)"/>
<g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round">
<circle cx="32" cy="32" r="16"/>
<ellipse cx="32" cy="32" rx="7" ry="16"/>
<path d="M16.6 27h30.8M16.6 37h30.8"/>
</g>
<circle cx="32" cy="32" r="4.2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+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