From 133a45c109da5310add55824db21af5239951f93 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 10 Apr 2024 23:30:40 +0200 Subject: Adding upstream version 3.8.1. Signed-off-by: Daniel Baumann --- interface/js/app/common.js | 233 +++++++++++++++++++ interface/js/app/config.js | 246 ++++++++++++++++++++ interface/js/app/graph.js | 252 ++++++++++++++++++++ interface/js/app/history.js | 306 ++++++++++++++++++++++++ interface/js/app/libft.js | 524 ++++++++++++++++++++++++++++++++++++++++++ interface/js/app/rspamd.js | 486 +++++++++++++++++++++++++++++++++++++++ interface/js/app/selectors.js | 145 ++++++++++++ interface/js/app/stats.js | 372 ++++++++++++++++++++++++++++++ interface/js/app/symbols.js | 260 +++++++++++++++++++++ interface/js/app/upload.js | 243 ++++++++++++++++++++ 10 files changed, 3067 insertions(+) create mode 100644 interface/js/app/common.js create mode 100644 interface/js/app/config.js create mode 100644 interface/js/app/graph.js create mode 100644 interface/js/app/history.js create mode 100644 interface/js/app/libft.js create mode 100644 interface/js/app/rspamd.js create mode 100644 interface/js/app/selectors.js create mode 100644 interface/js/app/stats.js create mode 100644 interface/js/app/symbols.js create mode 100644 interface/js/app/upload.js (limited to 'interface/js/app') diff --git a/interface/js/app/common.js b/interface/js/app/common.js new file mode 100644 index 0000000..ea6102f --- /dev/null +++ b/interface/js/app/common.js @@ -0,0 +1,233 @@ +/* global jQuery */ + +define(["jquery", "nprogress"], + ($, NProgress) => { + "use strict"; + const ui = { + 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, + }); + + function getPassword() { + return sessionStorage.getItem("Password"); + } + + function alertMessage(alertClass, alertText) { + const a = $("
" + + "" + + "" + alertText + ""); + $(".notification-area").append(a); + + setTimeout(() => { + $(a).fadeTo(500, 0).slideUp(500, function () { + $(this).alert("close"); + }); + }, 5000); + } + + function queryServer(neighbours_status, ind, req_url, o) { + neighbours_status[ind].checked = false; + neighbours_status[ind].data = {}; + neighbours_status[ind].status = false; + const req_params = { + jsonp: false, + data: o.data, + headers: $.extend({Password: getPassword()}, o.headers), + url: neighbours_status[ind].url + req_url, + xhr: function () { + const xhr = $.ajaxSettings.xhr(); + // Download progress + 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); + } + return xhr; + }, + success: function (json) { + neighbours_status[ind].checked = true; + neighbours_status[ind].status = true; + neighbours_status[ind].data = json; + }, + error: function (jqXHR, textStatus, errorThrown) { + neighbours_status[ind].checked = true; + function errorMessage() { + alertMessage("alert-error", neighbours_status[ind].name + " > " + + (o.errorMessage ? o.errorMessage : "Request failed") + + (errorThrown ? ": " + errorThrown : "")); + } + if (o.error) { + o.error(neighbours_status[ind], + jqXHR, textStatus, errorThrown); + } else if (o.errorOnceId) { + const alert_status = o.errorOnceId + neighbours_status[ind].name; + if (!(alert_status in sessionStorage)) { + sessionStorage.setItem(alert_status, true); + errorMessage(); + } + } else { + errorMessage(); + } + }, + complete: function (jqXHR) { + if (neighbours_status.every((elt) => elt.checked)) { + if (neighbours_status.some((elt) => elt.status)) { + if (o.success) { + o.success(neighbours_status, jqXHR); + } else { + alertMessage("alert-success", "Request completed"); + } + } else { + alertMessage("alert-error", "Request failed"); + } + if (o.complete) o.complete(); + NProgress.done(); + } + }, + statusCode: o.statusCode + }; + if (o.method) { + req_params.method = o.method; + } + if (o.params) { + $.each(o.params, (k, v) => { + req_params[k] = v; + }); + } + $.ajax(req_params); + } + + + // Public functions + + ui.alertMessage = alertMessage; + ui.getPassword = getPassword; + + // Get selectors' current state + ui.getSelector = function (id) { + const e = document.getElementById(id); + return e.options[e.selectedIndex].value; + }; + + /** + * @param {string} url - A string containing the URL to which the request is sent + * @param {Object} [options] - A set of key/value pairs that configure the Ajax request. All settings are optional. + * + * @param {Function} [options.complete] - A function to be called when the requests to all neighbours complete. + * @param {Object|string|Array} [options.data] - Data to be sent to the server. + * @param {Function} [options.error] - A function to be called if the request fails. + * @param {string} [options.errorMessage] - Text to display in the alert message if the request fails. + * @param {string} [options.errorOnceId] - A prefix of the alert ID to be added to the session storage. If the + * parameter is set, the error for each server will be displayed only once per session. + * @param {Object} [options.headers] - An object of additional header key/value pairs to send along with requests + * using the XMLHttpRequest transport. + * @param {string} [options.method] - The HTTP method to use for the request. + * @param {Object} [options.params] - An object of additional jQuery.ajax() settings key/value pairs. + * @param {string} [options.server] - A server to which send the request. + * @param {Function} [options.success] - A function to be called if the request succeeds. + * + * @returns {undefined} + */ + 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 (jQuery.isEmptyObject(data)) { + ui.neighbours = { + local: { + host: window.location.host, + url: window.location.origin + window.location.pathname + } + }; + } else { + ui.neighbours = data; + } + neighbours_status = []; + $.each(ui.neighbours, (ind) => { + neighbours_status.push({ + name: ind, + host: ui.neighbours[ind].host, + url: ui.neighbours[ind].url, + }); + }); + $.each(neighbours_status, (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]); + }; + + return ui; + }); diff --git a/interface/js/app/config.js b/interface/js/app/config.js new file mode 100644 index 0000000..6be1075 --- /dev/null +++ b/interface/js/app/config.js @@ -0,0 +1,246 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global require */ + +define(["jquery", "app/common"], + ($, common) => { + "use strict"; + const ui = {}; + + ui.getActions = function getActions(checked_server) { + common.query("actions", { + success: function (data) { + $("#actionsFormField").empty(); + const items = []; + $.each(data[0].data, (i, item) => { + const actionsOrder = ["greylist", "add header", "rewrite subject", "reject"]; + const idx = actionsOrder.indexOf(item.action); + if (idx >= 0) { + items.push({ + idx: idx, + html: + '
' + + '" + + '
' + + '' + + "
" + + "
" + }); + } + }); + + items.sort((a, b) => a.idx - b.idx); + + $("#actionsFormField").html( + items.map((e) => e.html).join("")); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }; + + ui.saveActions = function (server) { + function descending(arr) { + let desc = true; + const filtered = arr.filter((el) => el !== null); + for (let i = 0; i < filtered.length - 1; i++) { + if (filtered[i + 1] >= filtered[i]) { + desc = false; + break; + } + } + return desc; + } + + const elts = (function () { + const values = []; + const inputs = $("#actionsForm :input[data-id=\"action\"]"); + // Rspamd order: [spam, rewrite_subject, probable_spam, greylist] + values[0] = parseFloat(inputs[3].value); + values[1] = parseFloat(inputs[2].value); + values[2] = parseFloat(inputs[1].value); + values[3] = parseFloat(inputs[0].value); + + return JSON.stringify(values); + }()); + // String to array for comparison + const eltsArray = JSON.parse(elts); + if (eltsArray[0] < 0) { + common.alertMessage("alert-modal alert-error", "Spam can not be negative"); + } else if (eltsArray[1] < 0) { + common.alertMessage("alert-modal alert-error", "Rewrite subject can not be negative"); + } else if (eltsArray[2] < 0) { + common.alertMessage("alert-modal alert-error", "Probable spam can not be negative"); + } else if (eltsArray[3] < 0) { + common.alertMessage("alert-modal alert-error", "Greylist can not be negative"); + } else if (descending(eltsArray)) { + common.query("saveactions", { + method: "POST", + params: { + data: elts, + dataType: "json" + }, + server: server + }); + } else { + common.alertMessage("alert-modal alert-error", "Incorrect order of actions thresholds"); + } + }; + + ui.getMaps = function (checked_server) { + const $listmaps = $("#listMaps"); + $listmaps.closest(".card").hide(); + common.query("maps", { + success: function (json) { + const [{data}] = json; + $listmaps.empty(); + $("#modalBody").empty(); + const $tbody = $(""); + + $.each(data, (i, item) => { + let $td = 'Read'; + if (!(item.editable === false || common.read_only)) { + $td = $($td).append(' Write'); + } + const $tr = $("").append($td); + + const $span = $('' + + item.uri + "").data("item", item); + $span.wrap("").parent().appendTo($tr); + $("" + item.description + "").appendTo($tr); + $tr.appendTo($tbody); + }); + $tbody.appendTo($listmaps); + $listmaps.closest(".card").show(); + }, + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + }; + + + let jar = {}; + const editor = { + advanced: { + codejar: true, + elt: "div", + class: "editor language-clike", + readonly_attr: {contenteditable: false}, + }, + basic: { + elt: "textarea", + class: "form-control map-textarea", + readonly_attr: {readonly: true}, + } + }; + let mode = "advanced"; + + // Modal form for maps + $(document).on("click", "[data-bs-toggle=\"modal\"]", function () { + const checked_server = common.getSelector("selSrv"); + const item = $(this).data("item"); + common.query("getmap", { + headers: { + Map: item.map + }, + success: function (data) { + // Highlighting a large amount of text is unresponsive + mode = (new Blob([data[0].data]).size > 5120) ? "basic" : $("input[name=editorMode]:checked").val(); + + $("<" + editor[mode].elt + ' id="editor" class="' + editor[mode].class + '" data-id="' + item.map + + '">").appendTo("#modalBody"); + + if (editor[mode].codejar) { + require(["codejar", "linenumbers", "prism"], (CodeJar, withLineNumbers, Prism) => { + jar = new CodeJar( + document.querySelector("#editor"), + withLineNumbers((el) => Prism.highlightElement(el)) + ); + jar.updateCode(data[0].data); + }); + } else { + document.querySelector("#editor").innerHTML = common.escapeHTML(data[0].data); + } + + let icon = "fa-edit"; + if (item.editable === false || common.read_only) { + $("#editor").attr(editor[mode].readonly_attr); + icon = "fa-eye"; + $("#modalSaveGroup").hide(); + } else { + $("#modalSaveGroup").show(); + } + $("#modalDialog .modal-header").find("[data-fa-i2svg]").addClass(icon); + $("#modalTitle").html(item.uri); + + $("#modalDialog").modal("show"); + }, + errorMessage: "Cannot receive maps data", + server: (checked_server === "All SERVERS") ? "local" : checked_server + }); + return false; + }); + $("#modalDialog").on("hidden.bs.modal", () => { + if (editor[mode].codejar) { + jar.destroy(); + $(".codejar-wrap").remove(); + } else { + $("#editor").remove(); + } + }); + + $("#saveActionsBtn").on("click", () => { + ui.saveActions(); + }); + $("#saveActionsClusterBtn").on("click", () => { + ui.saveActions("All SERVERS"); + }); + + function saveMap(server) { + common.query("savemap", { + success: function () { + common.alertMessage("alert-success", "Map data successfully saved"); + $("#modalDialog").modal("hide"); + }, + errorMessage: "Save map error", + method: "POST", + headers: { + Map: $("#editor").data("id"), + }, + params: { + data: editor[mode].codejar ? jar.toString() : $("#editor").val(), + dataType: "text", + }, + server: server + }); + } + $("#modalSave").on("click", () => { + saveMap(); + }); + $("#modalSaveAll").on("click", () => { + saveMap("All SERVERS"); + }); + + return ui; + }); diff --git a/interface/js/app/graph.js b/interface/js/app/graph.js new file mode 100644 index 0000000..71306f4 --- /dev/null +++ b/interface/js/app/graph.js @@ -0,0 +1,252 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov + Copyright (C) 2017 Alexander Moisseev + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global FooTable */ + +define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"], + ($, common, D3Evolution, D3Pie, d3) => { + "use strict"; + + const rrd_pie_config = { + cornerRadius: 2, + size: { + canvasWidth: 400, + canvasHeight: 180, + pieInnerRadius: "50%", + pieOuterRadius: "80%" + }, + labels: { + outer: { + format: "none" + }, + inner: { + hideWhenLessThanPercentage: 8, + offset: 0 + }, + }, + padAngle: 0.02, + pieCenterOffset: { + x: -120, + y: 10, + }, + total: { + enabled: true + }, + }; + + const ui = {}; + let prevUnit = "msg/s"; + + ui.draw = function (graphs, neighbours, checked_server, type) { + const graph_options = { + title: "Rspamd throughput", + width: 1060, + height: 370, + yAxisLabel: "Message rate, msg/s", + + legend: { + space: 140, + entries: common.chartLegend + } + }; + + function initGraph() { + const graph = new D3Evolution("graph", $.extend({}, graph_options, { + yScale: common.getSelector("selYScale"), + type: common.getSelector("selType"), + interpolate: common.getSelector("selInterpolate"), + convert: common.getSelector("selConvert"), + })); + $("#selYScale").change(function () { + graph.yScale(this.value); + }); + $("#selConvert").change(function () { + graph.convert(this.value); + }); + $("#selType").change(function () { + graph.type(this.value); + }); + $("#selInterpolate").change(function () { + graph.interpolate(this.value); + }); + + return graph; + } + + function getRrdSummary(json, scaleFactor) { + const xExtents = d3.extent(d3.merge(json), (d) => d.x); + const timeInterval = xExtents[1] - xExtents[0]; + + let total = 0; + const rows = json.map((curr, i) => { + // Time intervals that don't have data are excluded from average calculation as d3.mean()ignores nulls + const avg = d3.mean(curr, (d) => d.y); + // To find an integral on the whole time interval we need to convert nulls to zeroes + // eslint-disable-next-line no-bitwise + const value = d3.mean(curr, (d) => Number(d.y)) * timeInterval / scaleFactor ^ 0; + const yExtents = d3.extent(curr, (d) => d.y); + + total += value; + return { + label: graph_options.legend.entries[i].label, + value: value, + min: Number(yExtents[0].toFixed(6)), + avg: Number(avg.toFixed(6)), + max: Number(yExtents[1].toFixed(6)), + last: Number(curr[curr.length - 1].y.toFixed(6)), + color: graph_options.legend.entries[i].color, + }; + }, []); + + return { + rows: rows, + total: total + }; + } + + function initSummaryTable(rows, unit) { + common.tables.rrd_summary = FooTable.init("#rrd-table", { + sorting: { + enabled: true + }, + columns: [ + {name: "label", title: "Action"}, + {name: "value", title: "Messages", defaultContent: ""}, + {name: "min", title: "Minimum, " + unit + "", defaultContent: ""}, + {name: "avg", title: "Average, " + unit + "", defaultContent: ""}, + {name: "max", title: "Maximum, " + unit + "", defaultContent: ""}, + {name: "last", title: "Last, " + unit}, + ], + rows: rows.map((curr, i) => ({ + options: { + style: { + color: graph_options.legend.entries[i].color + } + }, + value: curr + }), []) + }); + } + + function drawRrdTable(rows, unit) { + if (Object.prototype.hasOwnProperty.call(common.tables, "rrd_summary")) { + $.each(common.tables.rrd_summary.rows.all, (i, row) => { + row.val(rows[i], false, true); + }); + } else { + initSummaryTable(rows, unit); + } + } + + function updateWidgets(data) { + let rrd_summary = {rows: []}; + let unit = "msg/s"; + + if (data) { + // Autoranging + let scaleFactor = 1; + const yMax = d3.max(d3.merge(data), (d) => d.y); + if (yMax < 1) { + scaleFactor = 60; + unit = "msg/min"; + data.forEach((s) => { + s.forEach((d) => { + if (d.y !== null) { d.y *= scaleFactor; } + }); + }); + } + + rrd_summary = getRrdSummary(data, scaleFactor); + } + + if (!graphs.rrd_pie) graphs.rrd_pie = new D3Pie("rrd-pie", rrd_pie_config); + graphs.rrd_pie.data(rrd_summary.rows); + + graphs.graph.data(data); + if (unit !== prevUnit) { + graphs.graph.yAxisLabel("Message rate, " + unit); + $(".unit").text(unit); + prevUnit = unit; + } + drawRrdTable(rrd_summary.rows, unit); + document.getElementById("rrd-total-value").innerHTML = rrd_summary.total; + } + + if (!graphs.graph) { + graphs.graph = initGraph(); + } + + + common.query("graph", { + success: function (req_data) { + let data = null; + const neighbours_data = req_data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + + if (neighbours_data.length === 1) { + [data] = neighbours_data; + } else { + let time_match = true; + neighbours_data.reduce((res, curr, _, arr) => { + if ((curr[0][0].x !== res[0][0].x) || + (curr[0][curr[0].length - 1].x !== res[0][res[0].length - 1].x)) { + time_match = false; + common.alertMessage("alert-error", + "Neighbours time extents do not match. Check if time is synchronized on all servers."); + arr.splice(1); // Break out of .reduce() by mutating the source array + } + return curr; + }); + + if (time_match) { + data = neighbours_data.reduce((res, curr) => curr.map((action, j) => action.map((d, i) => ({ + x: d.x, + y: (res[j][i].y === null) ? d.y : res[j][i].y + d.y + })))); + } + } + updateWidgets(data); + }, + complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + errorMessage: "Cannot receive throughput data", + errorOnceId: "alerted_graph_", + data: {type: type} + }); + }; + + + // Handling mouse events on overlapping elements + $("#rrd-pie").mouseover(() => { + $("#rrd-pie,#rrd-pie-tooltip").css("z-index", "200"); + $("#rrd-table_toggle").css("z-index", "300"); + }); + $("#rrd-table_toggle").mouseover(() => { + $("#rrd-pie,#rrd-pie-tooltip").css("z-index", "0"); + $("#rrd-table_toggle").css("z-index", "0"); + }); + + return ui; + }); diff --git a/interface/js/app/history.js b/interface/js/app/history.js new file mode 100644 index 0000000..0d953ec --- /dev/null +++ b/interface/js/app/history.js @@ -0,0 +1,306 @@ +/* + The MIT License (MIT) + + Copyright (C) 2017 Vsevolod Stakhov + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +/* global FooTable */ + +define(["jquery", "app/common", "app/libft", "footable"], + ($, common, libft) => { + "use strict"; + const ui = {}; + let prevVersion = null; + + function process_history_legacy(data) { + const items = []; + + function compare(e1, e2) { return e1.name.localeCompare(e2.name); } + + $("#selSymOrder_history, label[for='selSymOrder_history']").hide(); + + $.each(data, (i, item) => { + item.time = libft.unix_time_format(item.unix_time); + libft.preprocess_item(item); + item.symbols = Object.keys(item.symbols) + .map((key) => item.symbols[key]) + .sort(compare) + .map((e) => e.name) + .join(", "); + item.time = { + value: libft.unix_time_format(item.unix_time), + options: { + sortValue: item.unix_time + } + }; + + items.push(item); + }); + + return {items: items}; + } + + function columns_legacy() { + return [{ + name: "id", + title: "ID", + style: { + width: 300, + maxWidth: 300, + overflow: "hidden", + textOverflow: "ellipsis", + wordBreak: "keep-all", + whiteSpace: "nowrap" + } + }, { + name: "ip", + title: "IP address", + breakpoints: "xs sm", + style: {width: 150, maxWidth: 150} + }, { + name: "action", + title: "Action", + style: {width: 110, maxWidth: 110} + }, { + name: "score", + title: "Score", + style: {maxWidth: 110}, + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "symbols", + title: "Symbols", + breakpoints: "all", + style: {width: 550, maxWidth: 550} + }, { + name: "size", + title: "Message size", + breakpoints: "xs sm", + style: {width: 120, maxWidth: 120}, + formatter: libft.formatBytesIEC + }, { + name: "scan_time", + title: "Scan time", + breakpoints: "xs sm", + style: {maxWidth: 80}, + sortValue: function (val) { return Number(val); } + }, { + sorted: true, + direction: "DESC", + name: "time", + title: "Time", + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "user", + title: "Authenticated user", + breakpoints: "xs sm", + style: {width: 200, maxWidth: 200} + }]; + } + + const columns = { + 2: libft.columns_v2("history"), + legacy: columns_legacy() + }; + + function process_history_data(data) { + const process_functions = { + 2: libft.process_history_v2, + legacy: process_history_legacy + }; + let pf = process_functions.legacy; + + if (data.version) { + const strkey = data.version.toString(); + if (process_functions[strkey]) { + pf = process_functions[strkey]; + } + } + + return pf(data, "history"); + } + + function get_history_columns(data) { + let func = columns.legacy; + + if (data.version) { + const strkey = data.version.toString(); + if (columns[strkey]) { + func = columns[strkey]; + } + } + + return func; + } + + ui.getHistory = function () { + common.query("history", { + success: function (req_data) { + function differentVersions(neighbours_data) { + const dv = neighbours_data.some((e) => e.version !== neighbours_data[0].version); + if (dv) { + common.alertMessage("alert-error", + "Neighbours history backend versions do not match. Cannot display history."); + return true; + } + return false; + } + + const neighbours_data = req_data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + if (neighbours_data.length && !differentVersions(neighbours_data)) { + let data = {}; + const [{version}] = neighbours_data; + if (version) { + data.rows = [].concat.apply([], neighbours_data + .map((e) => e.rows)); + data.version = version; + $("#legacy-history-badge").hide(); + } else { + // Legacy version + data = [].concat.apply([], neighbours_data); + $("#legacy-history-badge").show(); + } + const o = process_history_data(data); + const {items} = o; + common.symbols.history = o.symbols; + + if (Object.prototype.hasOwnProperty.call(common.tables, "history") && + version === prevVersion) { + common.tables.history.rows.load(items); + } else { + libft.destroyTable("history"); + // Is there a way to get an event when the table is destroyed? + setTimeout(() => { + libft.initHistoryTable(data, items, "history", get_history_columns(data), false); + }, 200); + } + prevVersion = version; + } else { + libft.destroyTable("history"); + } + }, + complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + errorMessage: "Cannot receive history", + }); + }; + + function initErrorsTable(rows) { + common.tables.errors = FooTable.init("#errorsLog", { + columns: [ + {sorted: true, + direction: "DESC", + name: "ts", + title: "Time", + style: {width: 300, maxWidth: 300}, + sortValue: function (val) { return Number(val.options.sortValue); }}, + {name: "type", + title: "Worker type", + breakpoints: "xs sm", + style: {width: 150, maxWidth: 150}}, + {name: "pid", + title: "PID", + breakpoints: "xs sm", + style: {width: 110, maxWidth: 110}}, + {name: "module", title: "Module"}, + {name: "id", title: "Internal ID"}, + {name: "message", title: "Message", breakpoints: "xs sm"}, + ], + rows: rows, + paging: { + enabled: true, + limit: 5, + size: common.page_size.errors + }, + filtering: { + enabled: true, + position: "left", + connectors: false + }, + sorting: { + enabled: true + } + }); + } + + ui.getErrors = function () { + if (common.read_only) return; + + common.query("errors", { + success: function (data) { + const neighbours_data = data + .filter((d) => d.status) // filter out unavailable neighbours + .map((d) => d.data); + const rows = [].concat.apply([], neighbours_data); + $.each(rows, (i, item) => { + item.ts = { + value: libft.unix_time_format(item.ts), + options: { + sortValue: item.ts + } + }; + }); + if (Object.prototype.hasOwnProperty.call(common.tables, "errors")) { + common.tables.errors.rows.load(rows); + } else { + initErrorsTable(rows); + } + } + }); + + $("#updateErrors").off("click"); + $("#updateErrors").on("click", (e) => { + e.preventDefault(); + ui.getErrors(); + }); + }; + + + libft.set_page_size("history", $("#history_page_size").val()); + libft.bindHistoryTableEventHandlers("history", 8); + + $("#updateHistory").off("click"); + $("#updateHistory").on("click", (e) => { + e.preventDefault(); + ui.getHistory(); + }); + + // @reset history log + $("#resetHistory").off("click"); + $("#resetHistory").on("click", (e) => { + e.preventDefault(); + if (!confirm("Are you sure you want to reset history log?")) { // eslint-disable-line no-alert + return; + } + libft.destroyTable("history"); + libft.destroyTable("errors"); + + common.query("historyreset", { + success: function () { + ui.getHistory(); + ui.getErrors(); + }, + errorMessage: "Cannot reset history log" + }); + }); + + return ui; + }); diff --git a/interface/js/app/libft.js b/interface/js/app/libft.js new file mode 100644 index 0000000..c69f74d --- /dev/null +++ b/interface/js/app/libft.js @@ -0,0 +1,524 @@ +/* global FooTable */ + +define(["jquery", "app/common", "footable"], + ($, common) => { + "use strict"; + const ui = {}; + + let pageSizeTimerId = null; + let pageSizeInvocationCounter = 0; + + function get_compare_function(table) { + const compare_functions = { + magnitude: function (e1, e2) { + return Math.abs(e2.score) - Math.abs(e1.score); + }, + name: function (e1, e2) { + return e1.name.localeCompare(e2.name); + }, + score: function (e1, e2) { + return e2.score - e1.score; + } + }; + + return compare_functions[common.getSelector("selSymOrder_" + table)]; + } + + function sort_symbols(o, compare_function) { + return Object.keys(o) + .map((key) => o[key]) + .sort(compare_function) + .map((e) => e.str) + .join("
\n"); + } + + + // Public functions + + ui.formatBytesIEC = function (bytes) { + // FooTable represents data as text even column type is "number". + if (!Number.isInteger(Number(bytes)) || bytes < 0) return "NaN"; + + const base = 1024; + const exponent = Math.floor(Math.log(bytes) / Math.log(base)); + + if (exponent > 8) return "∞"; + + const value = parseFloat((bytes / (base ** exponent)).toPrecision(3)); + let unit = "BKMGTPEZY"[exponent]; + if (exponent) unit += "iB"; + + return value + " " + unit; + }; + + ui.columns_v2 = function (table) { + return [{ + name: "id", + title: "ID", + style: { + minWidth: 130, + overflow: "hidden", + textOverflow: "ellipsis", + wordBreak: "break-all", + whiteSpace: "normal" + } + }, { + name: "ip", + title: "IP address", + breakpoints: "xs sm md", + style: { + "minWidth": "calc(7.6em + 8px)", + "word-break": "break-all" + }, + // Normalize IPv4 + sortValue: (ip) => ((typeof ip === "string") ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0") + }, { + name: "sender_mime", + title: "[Envelope From] From", + breakpoints: "xs sm md", + style: { + "minWidth": 100, + "maxWidth": 200, + "word-wrap": "break-word" + } + }, { + name: "rcpt_mime_short", + title: "[Envelope To] To/Cc/Bcc", + breakpoints: "xs sm md", + filterable: false, + classes: "d-none d-xl-table-cell", + style: { + "minWidth": 100, + "maxWidth": 200, + "word-wrap": "break-word" + } + }, { + name: "rcpt_mime", + title: "[Envelope To] To/Cc/Bcc", + breakpoints: "all", + style: {"word-wrap": "break-word"} + }, { + name: "subject", + title: "Subject", + breakpoints: "xs sm md", + style: { + "word-break": "break-all", + "minWidth": 150 + } + }, { + name: "action", + title: "Action", + style: {minwidth: 82} + }, { + name: "passthrough_module", + title: '
Pass-through module
', + breakpoints: "xs sm md" + }, { + name: "score", + title: "Score", + style: { + "maxWidth": 110, + "text-align": "right", + "white-space": "nowrap" + }, + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "symbols", + title: "Symbols" + + '
' + + '
Sort by:
' + + '
' + + '' + + '' + + '' + + "
" + + "
", + breakpoints: "all", + style: {width: 550, maxWidth: 550} + }, { + name: "size", + title: "Msg size", + breakpoints: "xs sm md", + style: {minwidth: 50}, + formatter: ui.formatBytesIEC + }, { + name: "time_real", + title: "Scan time", + breakpoints: "xs sm md", + style: {maxWidth: 72}, + sortValue: function (val) { return Number(val); } + }, { + classes: "history-col-time", + sorted: true, + direction: "DESC", + name: "time", + title: "Time", + sortValue: function (val) { return Number(val.options.sortValue); } + }, { + name: "user", + title: "Authenticated user", + breakpoints: "xs sm md", + style: { + "minWidth": 100, + "maxWidth": 130, + "word-wrap": "break-word" + } + }].filter((col) => { + switch (table) { + case "history": + return (col.name !== "passthrough_module"); + case "scan": + return ["ip", "sender_mime", "rcpt_mime_short", "rcpt_mime", "subject", "size", "user"] + .every((name) => col.name !== name); + default: + return null; + } + }); + }; + + ui.set_page_size = function (table, page_size, changeTablePageSize) { + const n = parseInt(page_size, 10); // HTML Input elements return string representing a number + if (n > 0) { + common.page_size[table] = n; + + if (changeTablePageSize && + $("#historyTable_" + table + " tbody").is(":parent")) { // Table is not empty + clearTimeout(pageSizeTimerId); + const t = FooTable.get("#historyTable_" + table); + if (t) { + pageSizeInvocationCounter = 0; + // Wait for input finish + pageSizeTimerId = setTimeout(() => t.pageSize(n), 1000); + } else if (++pageSizeInvocationCounter < 10) { + // Wait for FooTable instance ready + pageSizeTimerId = setTimeout(() => ui.set_page_size(table, n, true), 1000); + } + } + } + }; + + ui.bindHistoryTableEventHandlers = function (table, symbolsCol) { + function change_symbols_order(order) { + $(".btn-sym-" + table + "-" + order).addClass("active").siblings().removeClass("active"); + const compare_function = get_compare_function(table); + $.each(common.tables[table].rows.all, (i, row) => { + const cell_val = sort_symbols(common.symbols[table][i], compare_function); + row.cells[symbolsCol].val(cell_val, false, true); + }); + } + + $("#selSymOrder_" + table).unbind().change(function () { + const order = this.value; + change_symbols_order(order); + }); + $("#" + table + "_page_size").change((e) => ui.set_page_size(table, e.target.value, true)); + $(document).on("click", ".btn-sym-order-" + table + " input", function () { + const order = this.value; + $("#selSymOrder_" + table).val(order); + change_symbols_order(order); + }); + }; + + ui.destroyTable = function (table) { + if (common.tables[table]) { + common.tables[table].destroy(); + delete common.tables[table]; + } + }; + + ui.initHistoryTable = function (data, items, table, columns, expandFirst) { + /* eslint-disable no-underscore-dangle */ + FooTable.Cell.extend("collapse", function () { + // call the original method + this._super(); + // Copy cell classes to detail row tr element + this._setClasses(this.$detail); + }); + /* eslint-enable no-underscore-dangle */ + + /* eslint-disable consistent-this, no-underscore-dangle, one-var-declaration-per-line */ + FooTable.actionFilter = FooTable.Filtering.extend({ + construct: function (instance) { + this._super(instance); + this.actions = ["reject", "add header", "greylist", + "no action", "soft reject", "rewrite subject"]; + this.def = "Any action"; + this.$action = null; + }, + $create: function () { + this._super(); + const self = this; + const $form_grp = $("
", { + class: "form-group d-inline-flex align-items-center" + }).append($("