initial commit
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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 |
@@ -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();
|
||||
});
|
||||
})();
|
||||
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user