File: //proc/1/cwd/usr/share/rspamd/www/js/app/common.js
define(["nprogress"],
(NProgress) => {
"use strict";
const ui = {
breakpoints: {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1400
},
chartLegend: [
{label: "reject", color: "#FF0000"},
{label: "soft reject", color: "#BF8040"},
{label: "rewrite subject", color: "#FF6600"},
{label: "add header", color: "#FFAD00"},
{label: "greylist", color: "#436EEE"},
{label: "no action", color: "#66CC00"}
],
locale: (localStorage.getItem("selected_locale") === "custom") ? localStorage.getItem("custom_locale") : null,
neighbours: [],
page_size: {
scan: 25,
errors: 25,
history: 25
},
symbols: {
scan: [],
history: []
},
tables: {}
};
NProgress.configure({
minimum: 0.01,
showSpinner: false,
});
// --- AJAX plumbing (replaces $.ajaxSetup + $(document).ajaxStart/Complete) ---
let ajaxTimeout = 0; // milliseconds; 0 disables the per-request timeout
let activeAjaxCount = 0;
const ajaxStartCallbacks = [];
const ajaxCompleteCallbacks = [];
// Mirrors jQuery's global ajax events: ajaxStart fires only on the first
// outstanding request (0 -> 1); ajaxComplete fires once per completed
// request. Requests with params.global === false are invisible to both.
function fireAjaxStart() {
if (activeAjaxCount === 0) {
ajaxStartCallbacks.forEach((cb) => cb());
}
activeAjaxCount++;
}
function fireAjaxComplete() {
activeAjaxCount = Math.max(0, activeAjaxCount - 1);
ajaxCompleteCallbacks.forEach((cb) => cb());
}
ui.setAjaxTimeout = function (ms) {
ajaxTimeout = ms;
};
// Read by callers that issue direct XHRs (outside common.query) and need
// to honour the configured AJAX timeout.
ui.getAjaxTimeout = function () {
return ajaxTimeout;
};
ui.onAjaxStart = function (callback) {
ajaxStartCallbacks.push(callback);
};
ui.onAjaxComplete = function (callback) {
ajaxCompleteCallbacks.push(callback);
};
// --- Shared vanilla DOM helpers ---
// True for null/undefined without the `undefined` literal (no-undefined)
// or == null (eqeqeq / no-eq-null).
function isNil(value) {
return value === null || typeof value === "undefined";
}
// Resolve a string selector, Element, NodeList, Array, or jQuery wrapper
// into a plain Array of Element. Accepts jQuery objects during the staged
// removal so callers still passing $(...) keep working.
function toElements(selector) {
if (!selector) return [];
if (typeof selector === "string") {
return Array.from(document.querySelectorAll(selector));
}
// A single Element node — including <select>/<form>, which expose a
// numeric .length — must not be mistaken for a collection.
if (selector.nodeType === 1) return [selector];
if (typeof selector.length === "number") {
return Array.from(selector).filter(Boolean);
}
return [selector];
}
/**
* Create an element, apply attributes/properties/listeners, and append
* children. Recognised attrs keys: class, text, html, dataset, any DOM
* property, any attribute, and event names (click, change, input, ...)
* given as functions. Replaces the $("<tag>", {...}) idiom.
*/
ui.el = function (tag, attrs, ...children) {
const node = document.createElement(tag);
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (isNil(value)) continue;
switch (key) {
case "class": node.className = value; break;
case "text": node.textContent = value; break;
case "html": node.innerHTML = value; break;
case "dataset": Object.assign(node.dataset, value); break;
default:
if (typeof value === "function") {
node.addEventListener(key, value);
} else if (key in node) {
node[key] = value;
} else {
node.setAttribute(key, value);
}
}
}
}
for (const child of children) {
if (isNil(child)) continue;
node.append(child);
}
return node;
};
/**
* Event delegation: bind a single listener on `root` that invokes `fn`
* for the closest ancestor of the event target matching `selector`.
* `this` and the second argument are set to the matched element.
* Returns a cleanup function. Replaces $(root).on(type, selector, fn).
*/
ui.delegate = function (root, type, selector, fn) {
const rootEl = (typeof root === "string") ? document.querySelector(root) : root;
function handler(event) {
const target = event.target.closest(selector);
if (target && rootEl.contains(target)) {
fn.call(target, event, target);
}
}
rootEl.addEventListener(type, handler);
return function () {
rootEl.removeEventListener(type, handler);
};
};
// WeakMap-backed data store for values that cannot live in dataset
// (objects, arrays). Mirrors jQuery's .data() get/set for the few call
// sites that store non-string values.
const dataStore = new WeakMap();
ui.data = function (el, key, value) {
const node = (typeof el === "string") ? document.querySelector(el) : el;
if (!node) return null;
if (!dataStore.has(node)) dataStore.set(node, {});
const store = dataStore.get(node);
if (arguments.length >= 3) {
store[key] = value;
return node;
}
return store[key];
};
function getPassword() {
return sessionStorage.getItem("Password");
}
function alertMessage(alertClass, alertText) {
const a = document.createElement("div");
a.className = "alert " + alertClass + " alert-dismissible fade in show";
a.innerHTML = "<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\" title=\"Dismiss\"></button>" +
"<strong>" + alertText + "</strong>";
document.querySelector(".notification-area").append(a);
// Fade out, then collapse the height so siblings slide up, then remove.
setTimeout(() => {
a.animate(
[{opacity: 1}, {opacity: 0}],
{duration: 500, fill: "forwards"}
).onfinish = () => {
a.style.overflow = "hidden";
a.animate(
[{height: a.offsetHeight + "px"}, {height: 0}],
{duration: 500, easing: "ease", fill: "forwards"}
).onfinish = () => {
a.remove();
};
};
}, 5000);
}
// Forward declare updateErrorBadge to resolve circular dependency
// This function is called by errorLog methods but uses errorLog data
// Safe due to hoisting: function is called AFTER errorLog initialization
function updateErrorBadge() {
const unseenCount = errorLog.getUnseenCount(); // eslint-disable-line no-use-before-define
const totalCount = errorLog.errors.length; // eslint-disable-line no-use-before-define
const badge = document.getElementById("error-log-badge");
const counter = document.getElementById("error-count");
// Show badge if there are any errors
if (totalCount > 0) {
badge.classList.remove("d-none");
// Show counter only if there are unseen errors
if (unseenCount > 0) {
counter.classList.remove("d-none");
counter.textContent = unseenCount;
} else {
counter.classList.add("d-none");
}
} else {
badge.classList.add("d-none");
}
}
// Error log storage
const errorLog = {
errors: [],
maxSize: 50,
lastViewedIndex: -1, // Track last viewed error for "unseen" counter
add(entry) {
this.errors.push({
timestamp: new Date(),
server: entry.server ?? "Unknown",
endpoint: entry.endpoint ?? "",
message: entry.message ?? "Unknown error",
httpStatus: entry.httpStatus ?? null,
errorType: entry.errorType ?? "unknown"
});
// Keep last 50 errors
if (this.errors.length > this.maxSize) {
this.errors.shift();
// Adjust lastViewedIndex after shift
if (this.lastViewedIndex >= 0) {
this.lastViewedIndex--;
}
}
updateErrorBadge();
},
clear() {
this.errors = [];
this.lastViewedIndex = -1;
updateErrorBadge();
},
getAll() {
return this.errors;
},
markAsViewed() {
// Mark all current errors as viewed
this.lastViewedIndex = this.errors.length - 1;
updateErrorBadge();
},
getUnseenCount() {
// Return count of errors added since last view
return Math.max(0, this.errors.length - this.lastViewedIndex - 1);
}
};
function updateErrorLogTable() {
const table = document.getElementById("errorLogTable");
const tbody = table.querySelector("tbody");
const noErrors = document.getElementById("noErrorsMessage");
const copyBtn = document.getElementById("copyErrorLog");
const clearBtn = document.getElementById("clearErrorLog");
tbody.replaceChildren();
const hasErrors = errorLog.errors.length > 0;
if (!hasErrors) {
table.style.display = "none";
noErrors.style.display = "";
copyBtn.disabled = true;
clearBtn.disabled = true;
return;
}
table.style.display = "";
noErrors.style.display = "none";
copyBtn.disabled = false;
clearBtn.disabled = false;
// Show errors in reverse chronological order (newest first)
errorLog.errors.slice().reverse().forEach((err) => {
const time = ui.locale
? err.timestamp.toLocaleString(ui.locale)
: err.timestamp.toLocaleString();
const status = err.httpStatus ?? "-";
const row = document.createElement("tr");
// Map error types to Bootstrap badge colors
const errorTypeColors = {
auth: "text-bg-danger",
network: "text-bg-primary",
timeout: "text-bg-info",
http_error: "text-bg-warning",
data_inconsistency: "text-bg-secondary"
};
const badgeClass = errorTypeColors[err.errorType] || "text-bg-secondary";
// Column order: Time | Error | Server | Endpoint | HTTP Status | Type
row.append(ui.el("td", {class: "text-nowrap", text: time}));
row.append(ui.el("td", {text: err.message}));
row.append(ui.el("td", {class: "d-none d-sm-table-cell", text: err.server}));
row.append(ui.el("td", {class: "d-none d-md-table-cell"},
ui.el("code", {class: "small", text: err.endpoint})));
row.append(ui.el("td", {class: "d-none d-lg-table-cell text-center", text: status}));
row.append(ui.el("td", {class: "d-none d-lg-table-cell"},
ui.el("span", {class: "badge " + badgeClass, text: err.errorType})));
tbody.append(row);
});
}
/**
* Log error and optionally show alert message
*
* @param {Object} options - Error details
* @param {string} options.server - Server name or "Multi-server" for cluster-wide issues
* @param {string} [options.endpoint=""] - API endpoint or empty string
* @param {string} options.message - Error message
* @param {number} [options.httpStatus=null] - HTTP status code or null
* @param {string} options.errorType - Error type: timeout|auth|http_error|network|data_inconsistency
* @param {boolean} [options.showAlert=true] - Whether to show alert message
*/
function logError({httpStatus, endpoint, errorType, message, server, showAlert}) {
errorLog.add({httpStatus, endpoint, errorType, message, server});
if (showAlert !== false) {
const fullMessage = (server !== "Multi-server")
? server + " > " + message
: message;
alertMessage("alert-danger", fullMessage);
}
}
/**
* Perform a request to a single Rspamd neighbour server over XHR.
*
* @param {Array.<Object>} neighbours_status
* Array of neighbour status objects.
* @param {number} ind
* Index of this neighbour in the `neighbours_status` array.
* @param {string} req_url
* Relative controller endpoint with optional query string.
* @param {Object} o
* The same `options` object passed into `ui.query`.
*
* @returns {void}
*/
// Callers pre-stringify JSON bodies, so we never form-encode (jQuery's
// $.param). Normalise any stray object to a JSON string.
function normalizeRequestBody(rawBody) {
const isFormData = (typeof FormData !== "undefined") && (rawBody instanceof FormData);
if (!isNil(rawBody) && typeof rawBody === "object" && !isFormData) {
return {body: JSON.stringify(rawBody), isFormData};
}
return {body: rawBody, isFormData};
}
function hasContentType(headers) {
return ("Content-Type" in headers) || ("content-type" in headers);
}
// jQuery appends object data to the URL as a query string for GET/HEAD
// requests. Pre-encoded strings are returned unchanged. Only flat
// string/number values are supported (no arrays or nested objects);
// no current caller needs more — extend it if that changes.
function buildQueryString(data) {
if (typeof data === "string") return data;
if (!isNil(data) && typeof data === "object") {
return Object.entries(data)
.map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value))
.join("&");
}
return "";
}
function queryServer(neighbours_status, ind, req_url, o) {
neighbours_status[ind].checked = false;
neighbours_status[ind].data = {};
neighbours_status[ind].status = false;
const params = o.params || {};
const isGlobal = params.global !== false;
const method = (params.method || o.method || "GET").toUpperCase();
const timeout = (typeof params.timeout === "number") ? params.timeout : ajaxTimeout;
const {dataType} = params;
const headers = {Password: getPassword(), ...(o.headers || {})};
const url = neighbours_status[ind].url + req_url;
const rawBody = !isNil(params.data) ? params.data : o.data;
const isGet = method === "GET" || method === "HEAD";
const queryString = isGet ? buildQueryString(rawBody) : "";
const separator = url.indexOf("?") === -1 ? "?" : "&";
const requestUrl = url + (queryString ? separator + queryString : "");
// jQuery appends data to the URL for GET/HEAD and sends it as the
// body otherwise.
let body = null;
let isFormData = false;
if (!isGet) {
({body, isFormData} = normalizeRequestBody(rawBody));
}
const xhr = new XMLHttpRequest();
xhr.open(method, requestUrl, true);
Object.keys(headers).forEach((name) => xhr.setRequestHeader(name, headers[name]));
// jQuery's default Content-Type for non-FormData bodies. FormData is
// sent untouched so the browser sets the multipart boundary.
if (!isFormData && !isNil(body) && body !== "" && !hasContentType(headers)) {
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
}
if (timeout > 0) xhr.timeout = timeout;
// Download progress -> NProgress (skip the neighbours probe, as before)
if (req_url !== "neighbours") {
xhr.addEventListener("progress", (e) => {
if (e.lengthComputable) {
neighbours_status[ind].percentComplete = e.loaded / e.total;
const percentComplete = neighbours_status
.reduce((prev, curr) => (curr.percentComplete ? curr.percentComplete + prev : prev), 0);
NProgress.set(percentComplete / neighbours_status.length);
}
}, false);
}
function parseBody() {
if (dataType === "text") return xhr.responseText;
if (dataType === "json") return xhr.responseText ? JSON.parse(xhr.responseText) : null;
const contentType = xhr.getResponseHeader("Content-Type") || "";
if (contentType.indexOf("application/json") === 0 ||
contentType.indexOf("text/javascript") === 0) {
return xhr.responseText ? JSON.parse(xhr.responseText) : null;
}
return xhr.responseText;
}
// jQuery fires statusCode handlers once per request for the final
// status, in addition to success/error. Unlike jQuery, which passed
// (jqXHR, textStatus, errorThrown), the handler here is invoked as
// (responseText, statusText, xhr).
function runStatusCode() {
if (o.statusCode && typeof o.statusCode[xhr.status] === "function") {
o.statusCode[xhr.status](xhr.responseText, xhr.statusText, xhr);
}
}
function finish() {
runStatusCode();
if (neighbours_status.every((elt) => elt.checked)) {
if (neighbours_status.some((elt) => elt.status)) {
if (o.success) {
o.success(neighbours_status, xhr);
} else {
alertMessage("alert-success", "Request completed");
}
} else {
alertMessage("alert-danger", "Request failed");
}
if (o.complete) o.complete();
NProgress.done();
}
// Decrement after the success/complete callbacks (matching
// jQuery): a fan-out started inside success must bump the
// counter before this request's decrement, otherwise the
// in-flight count dips to 0 between the neighbours probe and
// the per-neighbour queries, re-firing ajaxStart and restarting
// the refresh spinner's animation mid-flight.
if (isGlobal) fireAjaxComplete();
}
function handleError(textStatus, errorThrown) {
neighbours_status[ind].checked = true;
// Determine error type and create detailed message
let errorType = "network";
let detailedMessage = errorThrown || "Request failed";
if (textStatus === "timeout") {
errorType = "timeout";
detailedMessage = "Request timeout";
} else if (xhr.status === 401 || xhr.status === 403) {
errorType = "auth";
detailedMessage = "Authentication failed";
} else if (xhr.status >= 400 && xhr.status < 600) {
errorType = "http_error";
detailedMessage = "HTTP " + xhr.status + (errorThrown ? ": " + errorThrown : "");
} else if (textStatus === "error" && xhr.status === 0) {
errorType = "network";
detailedMessage = "Network error";
}
// Log error and show alert
const shouldShowAlert = !o.error &&
!(o.errorOnceId && (o.errorOnceId + neighbours_status[ind].name) in sessionStorage);
if (o.errorOnceId && shouldShowAlert) {
sessionStorage.setItem(o.errorOnceId + neighbours_status[ind].name, true);
}
logError({
server: neighbours_status[ind].name,
endpoint: req_url,
message: o.errorMessage ? o.errorMessage + ": " + detailedMessage : detailedMessage,
httpStatus: xhr.status,
errorType: errorType,
showAlert: shouldShowAlert
});
// Call custom error handler if provided
if (o.error) o.error(neighbours_status[ind], xhr, textStatus, errorThrown);
finish();
}
xhr.onload = function () {
const ok = xhr.status >= 200 && xhr.status < 300;
if (!ok) {
handleError("error", xhr.statusText);
return;
}
try {
neighbours_status[ind].data = parseBody();
} catch (err) {
handleError("parsererror", err && err.message ? err.message : "Parse error");
return;
}
neighbours_status[ind].checked = true;
neighbours_status[ind].status = true;
finish();
};
xhr.onerror = function () {
handleError("error", "");
};
xhr.ontimeout = function () {
handleError("timeout", "timeout");
};
xhr.onabort = function () {
handleError("abort", "abort");
};
if (isGlobal) fireAjaxStart();
xhr.send(!isNil(body) ? body : null);
}
// Public functions
ui.alertMessage = alertMessage;
ui.getPassword = getPassword;
ui.logError = logError;
// Get selectors' current state
ui.getSelector = function (id) {
const e = document.getElementById(id);
return e.options[e.selectedIndex].value;
};
ui.getServer = function () {
const checked_server = ui.getSelector("selSrv");
return (checked_server === "All SERVERS") ? "local" : checked_server;
};
/**
* Perform an HTTP request to one or all Rspamd neighbours.
*
* @param {string} url
* Relative URL, including with optional query string (e.g. "plugins/selectors/check_selector?selector=from").
* @param {Object} [options]
* Request configuration options.
* @param {Object|string|Array} [options.data]
* Request body for POST endpoints. Callers must pre-stringify JSON;
* FormData is sent untouched.
* @param {Object} [options.headers]
* Additional HTTP headers.
* @param {"GET"|"POST"} [options.method]
* HTTP method (defaults to "GET").
* @param {string} [options.server]
* Name or base-URL of the target server (defaults to the currently selected Rspamd neighbour).
* @param {Object} [options.params]
* Extra request settings: global, timeout, dataType ("json"|"text"),
* statusCode, etc.
* @param {string} [options.errorMessage]
* Text to show inside a Bootstrap alert on generic errors (e.g. network failure).
* @param {string} [options.errorOnceId]
* Prefix for an alert ID stored in session storage to ensure
* `errorMessage` is shown only once per server each session.
* @param {function(Array.<Object>, Object)} [options.success]
* Called on HTTP success. Receives:
* 1. results: Array of per-server status objects:
* {
* name: string,
* host: string,
* url: string, // full URL base for this neighbour
* checked: boolean, // whether this server was attempted
* status: boolean, // HTTP success (<400)
* data: any, // parsed JSON or raw text
* percentComplete: number
* }
* 2. xhr: XMLHttpRequest (jqXHR-compatible: status, statusText,
* responseText, readyState).
* @param {function(Object, Object, string, string)} [options.error]
* Called on HTTP error or network failure. Receives:
* 1. result: a per-server status object (status:false, data:{}).
* 2. xhr: XMLHttpRequest (status, statusText, responseText).
* 3. textStatus: "error" | "timeout" | "abort" | "parsererror".
* 4. errorThrown: HTTP statusText or exception message.
* @param {function()} [options.complete]
* Called once all servers have been tried; takes no arguments.
*
* @returns {void}
*/
ui.query = function (url, options) {
// Force options to be an object
const o = options || {};
Object.keys(o).forEach((option) => {
if (["complete", "data", "error", "errorMessage", "errorOnceId", "headers", "method", "params", "server",
"statusCode", "success"]
.indexOf(option) < 0) {
throw new Error("Unknown option: " + option);
}
});
let neighbours_status = [{
name: "local",
host: "local",
url: "",
}];
o.server = o.server || ui.getSelector("selSrv");
if (o.server === "All SERVERS") {
queryServer(neighbours_status, 0, "neighbours", {
success: function (json) {
const [{data}] = json;
if (!data || Object.keys(data).length === 0) {
ui.neighbours = {
local: {
host: window.location.host,
url: window.location.origin + window.location.pathname
}
};
} else {
ui.neighbours = data;
}
neighbours_status = Object.keys(ui.neighbours).map((name) => ({
name: name,
host: ui.neighbours[name].host,
url: ui.neighbours[name].url,
}));
for (let ind = 0; ind < neighbours_status.length; ind++) {
queryServer(neighbours_status, ind, url, o);
}
},
errorMessage: "Cannot receive neighbours data"
});
} else {
if (o.server !== "local") {
neighbours_status = [{
name: o.server,
host: ui.neighbours[o.server].host,
url: ui.neighbours[o.server].url,
}];
}
queryServer(neighbours_status, 0, url, o);
}
};
ui.escapeHTML = function (string) {
const htmlEscaper = /[&<>"'/`=]/g;
const htmlEscapes = {
"&": "&",
"<": "<",
">": ">",
"\"": """,
"'": "'",
"/": "/",
"`": "`",
"=": "="
};
return String(string).replace(htmlEscaper, (match) => htmlEscapes[match]);
};
/**
* Hide one or more elements using Bootstrap's d-none class
* @param {string|Element|NodeList|Array} selector - CSS selector or element(s)
* @param {boolean} anim - Whether to animate (slide up)
*/
ui.hide = function (selector, anim = false) {
for (const el of toElements(selector)) {
if (anim) {
const height = el.offsetHeight;
el.style.overflow = "hidden";
const fx = el.animate(
[{height: height + "px"}, {height: 0}],
{duration: 400, easing: "ease", fill: "forwards"}
);
fx.onfinish = () => {
el.classList.add("d-none");
el.style.height = "";
el.style.overflow = "";
fx.cancel();
};
} else {
el.classList.add("d-none");
}
}
};
/**
* Show one or more elements using Bootstrap's d-none class
* @param {string|Element|NodeList|Array} selector - CSS selector or element(s)
* @param {boolean} anim - Whether to animate (slide down)
*/
ui.show = function (selector, anim = false) {
for (const el of toElements(selector)) {
if (anim) {
el.classList.remove("d-none");
const height = el.offsetHeight; // measure natural height now visible
el.style.overflow = "hidden";
el.style.height = "0";
const fx = el.animate(
[{height: 0}, {height: height + "px"}],
{duration: 400, easing: "ease", fill: "forwards"}
);
fx.onfinish = () => {
el.style.height = "";
el.style.overflow = "";
fx.cancel();
};
} else {
el.classList.remove("d-none");
}
}
};
/**
* Toggle visibility of one or more elements using Bootstrap's d-none class
* @param {string|Element|NodeList|Array} selector - CSS selector or element(s)
* @param {boolean} anim - Whether to animate
*/
ui.toggle = function (selector, anim = false) {
for (const el of toElements(selector)) {
if (el.classList.contains("d-none")) {
ui.show(el, anim);
} else {
ui.hide(el, anim);
}
}
};
ui.fileUtils = {
readFile(files, callback, index = 0) {
const file = files[index];
const reader = new FileReader();
reader.onerror = () => alertMessage("alert-danger", `Error reading file: ${file.name}`);
reader.onloadend = () => callback(reader.result);
reader.readAsText(file);
},
setFileInputFiles(fileInput, files, i) {
const dt = new DataTransfer();
if (arguments.length > 2) dt.items.add(files[i]);
const input = (typeof fileInput === "string") ? document.querySelector(fileInput) : fileInput;
input.files = dt.files;
},
setupFileHandling(textArea, fileInput, fileSet, enable_btn_cb, multiple_files_cb) {
const dragoverClassList = "outline-dashed-primary bg-primary-subtle";
const {readFile, setFileInputFiles} = ui.fileUtils;
const ta = (typeof textArea === "string") ? document.querySelector(textArea) : textArea;
const fi = (typeof fileInput === "string") ? document.querySelector(fileInput) : fileInput;
function handleFileInput(fileSource) {
fileSet.files = fileSource.files;
fileSet.index = 0;
const {files} = fileSet;
if (files.length === 1) {
setFileInputFiles(fileInput, files, 0);
enable_btn_cb();
readFile(files, (result) => {
ta.value = result;
enable_btn_cb();
});
} else if (multiple_files_cb) {
multiple_files_cb(files);
} else {
alertMessage("alert-warning", "Multiple files processing is not supported.");
}
}
["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
ta.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
["dragenter", "dragover"].forEach((evt) => {
ta.addEventListener(evt, () => ta.classList.add(...dragoverClassList.split(" ")));
});
["dragleave", "drop"].forEach((evt) => {
ta.addEventListener(evt, () => ta.classList.remove(...dragoverClassList.split(" ")));
});
ta.addEventListener("drop", (e) => handleFileInput(e.dataTransfer));
ta.addEventListener("input", () => {
enable_btn_cb();
if (fileSet.files) {
fileSet.files = null;
setFileInputFiles(fileInput, fileSet.files);
}
});
fi.addEventListener("change", (e) => handleFileInput(e.target));
}
};
ui.copyToClipboard = function (text) {
// Try modern Clipboard API first (HTTPS only)
const clip = navigator.clipboard;
if (clip && clip.writeText) return clip.writeText(text);
// Fallback for HTTP or older browsers using execCommand
return new Promise((resolve, reject) => {
const textarea = document.createElement("textarea");
textarea.value = text;
// Check if any modal is currently open
const modal = document.querySelector(".modal.show");
const modalBody = modal?.querySelector(".modal-body");
if (modalBody) {
// Inside open modal: use fixed positioning to avoid focus trap
textarea.style.position = "fixed";
textarea.style.top = "50%";
textarea.style.left = "50%";
textarea.style.opacity = "0";
modalBody.appendChild(textarea);
} else {
// Outside modal: use absolute positioning off-screen
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
}
try {
textarea.focus({preventScroll: true});
textarea.select();
const successful = document.execCommand("copy");
textarea.remove();
if (successful) {
resolve();
} else {
reject(new Error("Copy command failed"));
}
} catch (err) {
textarea.remove();
reject(err);
}
});
};
// Error log event handlers (bound once the DOM is ready)
function onReady(fn) {
if (document.readyState !== "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
onReady(() => {
const errorLogModal = document.getElementById("errorLogModal");
const clearBtn = document.getElementById("clearErrorLog");
const copyBtn = document.getElementById("copyErrorLog");
// Update error log table when modal is shown
errorLogModal.addEventListener("show.bs.modal", () => {
updateErrorLogTable();
// Mark all errors as viewed when modal is opened
errorLog.markAsViewed();
});
// Clear error log
clearBtn.addEventListener("click", () => {
errorLog.clear();
updateErrorLogTable();
});
// Copy error log to clipboard
copyBtn.addEventListener("click", () => {
if (errorLog.errors.length === 0) return;
const selection = window.getSelection();
let textToCopy = "";
// Check if user has selected text in the table
if (selection.toString().trim().length > 0) {
textToCopy = selection.toString();
} else {
// Copy entire log
const headers = ["Time", "Error", "Server", "Endpoint", "HTTP Status", "Type"];
textToCopy = headers.join("\t") + "\n";
errorLog.errors.slice().reverse().forEach((err) => {
const time = ui.locale
? err.timestamp.toLocaleString(ui.locale)
: err.timestamp.toLocaleString();
const status = err.httpStatus ?? "-";
const row = [time, err.message, err.server, err.endpoint, status, err.errorType];
textToCopy += row.join("\t") + "\n";
});
}
ui.copyToClipboard(textToCopy)
.then(() => {
// Show success feedback
const originalHtml = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => {
copyBtn.innerHTML = originalHtml;
}, 2000);
})
.catch((err) => alertMessage("alert-danger", "Failed to copy to clipboard: " + err.message));
});
});
return ui;
});