File: //proc/1/cwd/usr/share/rspamd/www/js/app/rspamd.js
/*
* Copyright (C) 2012-2013 Anton Simonov <untone@gmail.com>
* Copyright (C) 2014-2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
*/
/* global require, Visibility */
define(["app/common", "app/icons", "bootstrap",
"visibility"],
(common, icons, bootstrap) => {
"use strict";
const ui = {};
const defaultAjaxTimeout = 20000;
const ajaxTimeoutBox = ".popover #settings-popover #ajax-timeout";
const graphs = {};
let checked_server = "All SERVERS";
const timer_id = [];
let stickyTabsHandle = null;
function ajaxSetup(ajax_timeout, setFieldValue, saveToLocalStorage) {
const timeout = (ajax_timeout && ajax_timeout >= 0) ? ajax_timeout : defaultAjaxTimeout;
if (saveToLocalStorage) localStorage.setItem("ajax_timeout", timeout);
if (setFieldValue) document.querySelector(ajaxTimeoutBox).value = timeout;
common.setAjaxTimeout(timeout);
}
function cleanCredentials() {
sessionStorage.clear();
document.getElementById("statWidgets").replaceChildren();
const listMapsTbody = document.querySelector("#listMaps tbody");
if (listMapsTbody) listMapsTbody.replaceChildren();
document.getElementById("modalBody").replaceChildren();
}
function stopTimers() {
for (const key in timer_id) {
if (!{}.hasOwnProperty.call(timer_id, key)) continue;
Visibility.stop(timer_id[key]);
}
}
// Sticky Tabs: keep the active tab in sync with the URL hash so that the
// selected tab survives reload and is shareable. Vanilla rewrite of the
// former jquery.stickytabs plugin; uses the BS5 Tab API directly.
function initStickyTabs(navSelector, initialTab) {
const nav = document.querySelector(navSelector);
function showByHash() {
const selector = location.hash
? `a[href="${location.hash}"]`
: initialTab;
const link = selector ? nav.querySelector(selector) : null;
if (link) bootstrap.Tab.getOrCreateInstance(link).show();
}
showByHash();
window.addEventListener("hashchange", showByHash);
function onClick(e) {
const [, hash] = e.currentTarget.href.split("#");
if (history.pushState) {
history.pushState(null, "", location.pathname + location.search + "#" + hash);
}
}
nav.querySelectorAll("a").forEach((link) => link.addEventListener("click", onClick));
return {
destroy() {
window.removeEventListener("hashchange", showByHash);
nav.querySelectorAll("a").forEach((link) => link.removeEventListener("click", onClick));
nav.querySelectorAll(".nav-link.active").forEach((link) => link.classList.remove("active"));
}
};
}
function disconnect() {
[graphs, common.tables].forEach((o) => {
Object.keys(o).forEach((key) => {
o[key].destroy();
delete o[key];
});
});
// Remove sticky-tabs listeners and reset active tab state
if (stickyTabsHandle) {
stickyTabsHandle.destroy();
stickyTabsHandle = null;
}
stopTimers();
cleanCredentials();
ui.connect();
}
// Bootstrap disables non-button nav items (li/a) via the `disabled` attribute
// + `.disabled` class together; buttons additionally honour the attribute.
function disableNav(el) {
el.setAttribute("disabled", "disabled");
el.classList.add("disabled");
}
function enableNav(el) {
el.removeAttribute("disabled");
el.classList.remove("disabled");
}
// Active dropdown item's numeric data-value (jQuery .data() coerced to number).
function activeMenuValue(menuClass) {
const el = document.querySelector(".dropdown-menu a.active." + menuClass);
return el ? parseInt(el.dataset.value, 10) : null;
}
function tabClick(id) {
let tab_id = id;
if (document.querySelector(id)?.hasAttribute("disabled")) return;
let navBarControls = Array.from(document.querySelectorAll("#selSrv, #navBar li, #navBar a, #navBar button"));
if (id !== "#autoRefresh") navBarControls.forEach(disableNav);
stopTimers();
if (id === "#refresh" || id === "#autoRefresh") {
const active = document.querySelector(".nav-link.active");
if (active) tab_id = "#" + active.id;
}
document.getElementById("autoRefresh").classList.add("invisible");
document.getElementById("refresh").classList.add("radius-right");
function setAutoRefresh(refreshInterval, timer, callback) {
const refreshBtn = document.getElementById("refresh");
function countdown(interval) {
Visibility.stop(timer_id.countdown);
if (!interval) {
document.getElementById("countdown").textContent = "--:--";
return;
}
let timeLeft = interval;
document.getElementById("countdown").textContent = "00:00";
timer_id.countdown = Visibility.every(1000, 1000, () => {
timeLeft -= 1000;
document.getElementById("countdown").textContent = new Date(timeLeft).toISOString().substr(14, 5);
if (timeLeft <= 0) Visibility.stop(timer_id.countdown);
});
}
refreshBtn.classList.remove("radius-right");
document.getElementById("autoRefresh").classList.remove("invisible");
countdown(refreshInterval);
if (!refreshInterval) return;
timer_id[timer] = Visibility.every(refreshInterval, () => {
countdown(refreshInterval);
if (refreshBtn.hasAttribute("disabled")) return;
disableNav(refreshBtn);
callback();
});
}
if (["#scan_nav", "#selectors_nav", "#disconnect"].indexOf(tab_id) !== -1) {
document.getElementById("refresh").classList.add("invisible");
} else {
document.getElementById("refresh").classList.remove("invisible");
}
switch (tab_id) {
case "#status_nav":
require(["app/stats"], (module) => {
const refreshInterval = activeMenuValue("preset");
setAutoRefresh(refreshInterval, "status",
() => module.statWidgets(graphs, checked_server));
if (id !== "#autoRefresh") module.statWidgets(graphs, checked_server);
common.show(".preset");
common.hide(".history");
common.hide(".dynamic");
});
break;
case "#throughput_nav":
require(["app/graph"], (module) => {
const selData = common.getSelector("selData"); // Graph's dataset selector state
const step = {
day: 60000,
week: 300000
};
let refreshInterval = step[selData] || 3600000;
document.getElementById("dynamic-item").textContent = (refreshInterval / 60000) + " min";
if (!activeMenuValue("dynamic")) {
refreshInterval = null;
}
setAutoRefresh(refreshInterval, "throughput",
() => module.draw(graphs, common.neighbours, checked_server, selData));
if (id !== "#autoRefresh") module.draw(graphs, common.neighbours, checked_server, selData);
common.hide(".preset");
common.hide(".history");
common.show(".dynamic");
});
break;
case "#configuration_nav":
require(["app/config"], (module) => {
module.getActions();
module.getMaps();
});
break;
case "#symbols_nav":
require(["app/symbols"], (module) => module.getSymbols());
break;
case "#scan_nav":
require(["app/upload"], (module) => {
module.getClassifiers();
module.getFuzzyStorages();
});
break;
case "#selectors_nav":
require(["app/selectors"], (module) => module.displayUI());
break;
case "#history_nav":
require(["app/history"], (module) => {
function getHistoryAndErrors() {
module.getHistory();
module.getErrors();
}
const refreshInterval = activeMenuValue("history");
setAutoRefresh(refreshInterval, "history",
() => getHistoryAndErrors());
if (id !== "#autoRefresh") getHistoryAndErrors();
common.hide(".preset");
common.show(".history");
common.hide(".dynamic");
module.updateHistoryControlsState();
});
break;
case "#disconnect":
disconnect();
break;
default:
}
setTimeout(() => {
// Do not enable Refresh button until AJAX requests to all neighbours are finished
if (tab_id === "#history_nav") navBarControls = navBarControls.filter((el) => el.id !== "refresh");
navBarControls.forEach(enableNav);
}, (id === "#autoRefresh") ? 0 : 1000);
}
function saveCredentials(password) {
sessionStorage.setItem("Password", password);
}
function displayUI() {
// In many browsers local storage can only store string.
// So when we store the boolean true or false, it actually stores the strings "true" or "false".
common.read_only = sessionStorage.getItem("read_only") === "true";
common.query("auth", {
success: function (neighbours_status) {
const selSrv = document.getElementById("selSrv");
selSrv.replaceChildren();
selSrv.append(common.el("option", {value: "All SERVERS", text: "All SERVERS"}));
neighbours_status.forEach((e) => {
const selected = checked_server === e.name;
const disabled = !selected && !e.status;
selSrv.insertAdjacentHTML("beforeend",
'<option value="' + common.escapeHTML(e.name) + '"' +
(selected ? " selected" : "") + (disabled ? " disabled" : "") + ">" +
common.escapeHTML(e.name) + "</option>");
});
},
complete: function () {
ajaxSetup(localStorage.getItem("ajax_timeout"));
if (require.defined("app/upload")) require(["app/upload"], (module) => module.getClassifiers());
document.querySelectorAll(".ro-disable").forEach((el) => { el.disabled = common.read_only; });
if (common.read_only) {
common.hide(".ro-hide");
common.show(".ro-show");
} else {
common.show(".ro-hide");
common.hide(".ro-show");
}
document.getElementById("preloader").classList.add("d-none");
document.querySelectorAll("#navBar, #mainUI").forEach((el) => el.classList.remove("d-none"));
stickyTabsHandle = initStickyTabs(".nav-tabs-sticky", "#status_nav");
},
errorMessage: "Cannot get server status",
server: "All SERVERS"
});
}
// Show the connect (login) dialog and wire its form submission.
function showConnectDialog() {
const connectDialog = document.getElementById("connectDialog");
const connectForm = document.getElementById("connectForm");
const connectPassword = document.getElementById("connectPassword");
function clearFeedback() {
connectPassword.oninput = null;
connectPassword.classList.remove("is-invalid");
common.hide("#authInvalidCharFeedback,#authUnauthorizedFeedback");
}
connectDialog.addEventListener("show.bs.modal", () => clearFeedback(), {once: true});
connectDialog.addEventListener("shown.bs.modal", () => connectPassword.focus(), {once: true});
bootstrap.Modal.getOrCreateInstance(connectDialog).show();
connectForm.onsubmit = (e) => {
e.preventDefault();
const password = connectPassword.value;
function invalidFeedback(tooltip) {
connectPassword.classList.add("is-invalid");
connectPassword.oninput = () => clearFeedback();
common.show(tooltip);
}
if (!(/^[ -~]*$/).test(password)) {
invalidFeedback("#authInvalidCharFeedback");
connectPassword.focus();
return;
}
common.query("auth", {
headers: {
Password: password
},
success: function (json) {
const [{data}] = json;
connectPassword.value = "";
if (data.auth === "ok") {
sessionStorage.setItem("read_only", data.read_only);
saveCredentials(password);
connectForm.onsubmit = null;
bootstrap.Modal.getOrCreateInstance(connectDialog).hide();
displayUI();
}
},
error: function (_result, xhr, textStatus) {
if (xhr.statusText === "Unauthorized") {
invalidFeedback("#authUnauthorizedFeedback");
} else {
common.alertMessage("alert-modal alert-danger",
textStatus === "timeout" ? "Request timeout" : xhr.statusText);
}
connectPassword.value = "";
connectPassword.focus();
},
params: {
global: false,
},
server: "local"
});
};
}
// Public functions
ui.connect = function () {
// Prevent locking out of the WebUI if timeout is too low.
let timeout = localStorage.getItem("ajax_timeout");
if (timeout < defaultAjaxTimeout) timeout = defaultAjaxTimeout;
ajaxSetup(timeout);
// Query "/stat" to check if user is already logged in or client ip matches "secure_ip"
const xhr = new XMLHttpRequest();
xhr.open("GET", "stat", true);
const ajaxTimeout = common.getAjaxTimeout();
if (ajaxTimeout > 0) xhr.timeout = ajaxTimeout;
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
sessionStorage.setItem("read_only", data.read_only);
displayUI();
} catch (err) {
// A 2xx /stat whose body isn't JSON means a broken response
// (truncated by a proxy, a captive-portal page, etc.): show
// the login dialog rather than an empty, half-loaded UI.
showConnectDialog();
}
} else {
showConnectDialog();
}
};
xhr.onerror = () => showConnectDialog();
xhr.ontimeout = () => showConnectDialog();
xhr.send();
};
function updateThemeIcon(theme) {
const iconMap = {light: "fa-sun", dark: "fa-moon", auto: "fa-display"};
const icon = document.getElementById("theme-icon");
if (icon) {
icons.setIcon(icon, iconMap[theme] || "fa-display");
}
}
// Check the radio within `selector` whose value matches.
function checkRadio(selector, value) {
document.querySelectorAll(selector).forEach((radio) => {
radio.checked = radio.value === value;
});
}
(function initSettings() {
let selected_locale = null;
let custom_locale = null;
const localeTextbox = ".popover #settings-popover #locale";
const historyCountDef = 1000;
const historyCountSelector = ".popover #settings-popover #settings-history-count";
function validateLocale(saveToLocalStorage) {
const localeInput = document.querySelector(localeTextbox);
function toggle_form_group_class(remove, add) {
localeInput.classList.remove("is-" + remove);
localeInput.classList.add("is-" + add);
}
const now = new Date();
if (custom_locale.length) {
try {
now.toLocaleString(custom_locale);
if (saveToLocalStorage) localStorage.setItem("custom_locale", custom_locale);
common.locale = (selected_locale === "custom") ? custom_locale : null;
toggle_form_group_class("invalid", "valid");
} catch (err) {
common.locale = null;
toggle_form_group_class("valid", "invalid");
}
} else {
if (saveToLocalStorage) localStorage.setItem("custom_locale", null);
common.locale = null;
localeInput.classList.remove("is-valid", "is-invalid");
}
// Display date example
document.querySelector(".popover #settings-popover #date-example").textContent =
(common.locale)
? now.toLocaleString(common.locale)
: now.toLocaleString();
}
const settingsBtn = document.getElementById("settings");
bootstrap.Popover.getOrCreateInstance(settingsBtn, {
container: "body",
placement: "bottom",
html: true,
sanitize: false,
content: function () {
// Using .clone() has the side-effect of producing elements with duplicate id attributes.
return document.getElementById("settings-popover").cloneNode(true);
}
});
// Restore the tooltip of the element that the popover is attached to.
const originalTitle = settingsBtn.getAttribute("data-original-title");
if (originalTitle !== null) settingsBtn.setAttribute("title", originalTitle);
settingsBtn.addEventListener("click", (e) => {
e.preventDefault();
});
settingsBtn.addEventListener("inserted.bs.popover", () => {
selected_locale = localStorage.getItem("selected_locale") || "browser";
custom_locale = localStorage.getItem("custom_locale") || "";
validateLocale();
checkRadio('.popover #settings-popover input[type="radio"][name="locale"]', selected_locale);
document.querySelector(localeTextbox).value = custom_locale;
ajaxSetup(localStorage.getItem("ajax_timeout"), true);
document.querySelector(historyCountSelector).value =
parseInt(localStorage.getItem("historyCount"), 10) || historyCountDef;
// Restore theme selection
const savedTheme = localStorage.getItem("theme") || "auto";
checkRadio('.popover #settings-popover input[type="radio"][name="theme"]', savedTheme);
});
common.delegate(document, "change", '.popover #settings-popover input[type="radio"][name="locale"]',
(event, target) => {
selected_locale = target.value;
localStorage.setItem("selected_locale", selected_locale);
validateLocale();
});
common.delegate(document, "input", localeTextbox, (event, target) => {
custom_locale = target.value;
validateLocale(true);
});
common.delegate(document, "change", '.popover #settings-popover input[type="radio"][name="theme"]',
(event, target) => {
const theme = target.value;
if (window.rspamd && window.rspamd.theme) {
window.rspamd.theme.applyPreference(theme);
}
updateThemeIcon(theme || "auto");
});
updateThemeIcon(localStorage.getItem("theme") || "auto");
common.delegate(document, "input", ajaxTimeoutBox, (event, target) => {
ajaxSetup(target.value, false, true);
});
common.delegate(document, "click", ".popover #settings-popover #ajax-timeout-restore", () => {
ajaxSetup(null, true, true);
});
common.delegate(document, "input", historyCountSelector, (event, target) => {
const v = parseInt(target.value, 10);
if (v > 0) {
localStorage.setItem("historyCount", v);
target.classList.remove("is-invalid");
const historyCount = document.getElementById("history-count");
historyCount.value = v;
historyCount.dispatchEvent(new Event("change", {bubbles: true}));
} else {
target.classList.add("is-invalid");
}
});
common.delegate(document, "click", ".popover #settings-popover #settings-history-count-restore",
() => {
localStorage.removeItem("historyCount");
document.querySelector(historyCountSelector).value = historyCountDef;
});
// Dismiss Bootstrap popover by clicking outside
document.body.addEventListener("click", (e) => {
document.querySelectorAll(".popover").forEach((popover) => {
const triggerBtn = e.target.closest("button");
if (popover.contains(e.target) ||
(triggerBtn && triggerBtn.getAttribute("aria-describedby") === popover.id)) return;
bootstrap.Popover.getOrCreateInstance(settingsBtn).hide();
});
});
}());
document.getElementById("selData").addEventListener("change", () => {
tabClick("#throughput_nav");
});
function refreshSpinStart() {
document.querySelectorAll("#refresh > svg").forEach((el) => el.classList.add("fa-spin"));
}
function refreshSpinStop() {
setTimeout(() => {
document.querySelectorAll("#refresh > svg").forEach((el) => el.classList.remove("fa-spin"));
}, 1000);
}
// Drive the refresh spinner from common.query (XHR) requests.
common.onAjaxStart(refreshSpinStart);
common.onAjaxComplete(refreshSpinStop);
document.querySelectorAll('a[data-bs-toggle="tab"]').forEach((link) => {
link.addEventListener("shown.bs.tab", (e) => {
tabClick("#" + e.currentTarget.id);
});
});
document.querySelectorAll("#refresh, #disconnect").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
tabClick("#" + e.currentTarget.id);
});
});
document.querySelectorAll(".dropdown-menu a").forEach((item) => {
item.addEventListener("click", (e) => {
e.preventDefault();
const match = (/\b(?:dynamic|history|preset)\b/).exec(e.currentTarget.className);
if (!match) return;
const [menuClass] = match;
document.querySelectorAll(".dropdown-menu a.active." + menuClass)
.forEach((el) => el.classList.remove("active"));
e.currentTarget.classList.add("active");
tabClick("#autoRefresh");
});
});
document.getElementById("theme-toggle").addEventListener("click", (e) => {
e.preventDefault();
const currentTheme = localStorage.getItem("theme") || "auto";
// Cycle through: light -> dark -> auto -> light
const themeMap = {light: "dark", dark: "auto", auto: "light"};
const newTheme = themeMap[currentTheme] || "light";
if (window.rspamd && window.rspamd.theme) {
window.rspamd.theme.applyPreference(newTheme);
}
updateThemeIcon(newTheme);
// Update radio button in settings popover if it's open
checkRadio('.popover #settings-popover input[type="radio"][name="theme"]', newTheme);
});
document.getElementById("selSrv").addEventListener("change", (e) => {
checked_server = e.currentTarget.value;
if (checked_server === "All SERVERS") {
document.getElementById("learnServers").classList.remove("invisible");
} else {
document.getElementById("learnServers").classList.add("invisible");
}
const active = document.querySelector("#tablist > .nav-item > .nav-link.active");
if (active) tabClick("#" + active.id);
});
// Radio buttons
common.delegate(document, "click", 'input[type="radio"][name="clusterName"]', (event, target) => {
if (!target.disabled) {
checked_server = target.value;
tabClick("#status_nav");
}
});
document.getElementById("loading").classList.add("d-none");
return ui;
});