File: //proc/1/task/1/root/usr/share/rspamd/www/js/app/stats.js
/*
* Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
*/
define(["jquery", "app/common", "d3pie", "d3"],
($, common, D3Pie, d3) => {
"use strict";
// @ ms to date
function msToTime(seconds) {
if (!Number.isFinite(seconds)) return "???";
/* eslint-disable no-bitwise */
const years = seconds / 31536000 >> 0; // 3600*24*365
const months = seconds % 31536000 / 2628000 >> 0; // 3600*24*365/12
const days = seconds % 31536000 % 2628000 / 86400 >> 0; // 24*3600
const hours = seconds % 31536000 % 2628000 % 86400 / 3600 >> 0;
const minutes = seconds % 31536000 % 2628000 % 86400 % 3600 / 60 >> 0;
/* eslint-enable no-bitwise */
// eslint-disable-next-line no-useless-assignment
let out = null;
if (years > 0) {
if (months > 0) {
out = years + "yr " + months + "mth";
} else {
out = years + "yr " + days + "d";
}
} else if (months > 0) {
out = months + "mth " + days + "d";
} else if (days > 0) {
out = days + "d " + hours + "hr";
} else if (hours > 0) {
out = hours + "hr " + minutes + "min";
} else {
out = minutes + "min";
}
return out;
}
let rowspanHoverHandlersInitialized = false;
function attachRowspanHoverHandlers(tableId) {
const $table = $(tableId);
function findRowspanCells($row) {
const headerCount = $table.find("thead th").length;
if ($row.find("td").length >= headerCount) return [];
const result = [];
$row.prevAll().find("td[rowspan]").each((_, el) => {
const $cell = $(el);
const rowspan = parseInt($cell.attr("rowspan"), 10);
const distance = $row.prevAll().length - $cell.parent().prevAll().length;
if (distance < rowspan) {
result.push($cell);
}
});
return result;
}
$table.on("mouseenter", "tbody td", (event) => {
const $cell = $(event.currentTarget);
const $row = $cell.parent();
if ($cell.attr("rowspan")) {
// Hovering over rowspan cell - highlight entire group
const rowspan = parseInt($cell.attr("rowspan"), 10);
$cell.addClass("table-hover-cell");
$row.find("td").addClass("table-hover-cell");
$row.nextAll().slice(0, rowspan - 1).find("td").addClass("table-hover-cell");
// Also highlight parent rowspan cells (e.g., server when hovering classifier)
findRowspanCells($row).forEach(($parentCell) => {
if ($parentCell[0] !== $cell[0]) $parentCell.addClass("table-hover-cell");
});
} else {
// Hovering over regular cell - highlight current row
$row.find("td").addClass("table-hover-cell");
// Highlight all rowspan cells for this row
findRowspanCells($row).forEach(($rowspanCell) => $rowspanCell.addClass("table-hover-cell"));
}
}).on("mouseleave", "tbody td", () => $table.find("tbody td").removeClass("table-hover-cell"));
}
function displayStatWidgets(checked_server) {
const servers = JSON.parse(sessionStorage.getItem("Credentials") || "{}");
const data = servers[checked_server]?.data ?? {};
const stat_w = [];
$("#statWidgets").empty();
common.hide("#statWidgets");
$.each(data, (i, item) => {
const widgetsOrder = ["scanned", "no action", "greylist", "add header", "rewrite subject", "reject", "learned"];
function widget(k, v, cls) {
const c = (typeof cls === "undefined") ? "" : cls;
const titleAtt = d3.format(",")(v) + " " + k;
return '<div class="card stat-box d-inline-block text-center shadow-sm me-3 px-3">' +
'<div class="widget overflow-hidden p-2' + c + '" title="' + titleAtt +
'"><strong class="d-block mt-2 mb-1 fw-bold">' +
d3.format(".3~s")(v) + "</strong>" + k + "</div></div>";
}
if (i === "auth" || i === "error") return; // Skip to the next iteration
if (i === "uptime" || i === "version") {
let cls = "border-end ";
let val = item;
if (i === "uptime") {
cls = "";
val = msToTime(item);
}
$('<div class="' + cls + 'float-start px-3"><strong class="d-block mt-2 mb-1 fw-bold">' +
val + "</strong>" + i + "</div>")
.appendTo("#statWidgets");
} else if (i === "actions") {
$.each(item, (action, count) => {
stat_w[widgetsOrder.indexOf(action)] = widget(action, count);
});
} else {
stat_w[widgetsOrder.indexOf(i)] = widget(i, item, " text-capitalize");
}
});
$.each(stat_w, (i, item) => {
$(item).appendTo("#statWidgets");
});
$("#statWidgets > div:not(.stat-box)")
.wrapAll('<div class="card stat-box text-center shadow-sm float-end">' +
'<div class="widget overflow-hidden p-2 text-capitalize"></div></div>');
$("#statWidgets").find("div.float-end").appendTo("#statWidgets");
common.show("#statWidgets");
$("#clusterTable tbody").empty();
$("#selSrv").empty();
$.each(servers, (key, val) => {
let row_class = "danger";
let glyph_status = "fas fa-times";
let version = "???";
let uptime = "???";
let short_id = "???";
let scan_times = {
data: "???",
title: ""
};
if (val.status) {
row_class = "success";
glyph_status = "fas fa-check";
if (Number.isFinite(val.data.uptime)) {
uptime = msToTime(val.data.uptime);
}
if ("version" in val.data) {
({version} = val.data);
}
if (key === "All SERVERS") {
short_id = "";
scan_times.data = "";
} else {
if ("config_id" in val.data) {
short_id = val.data.config_id.substring(0, 8);
}
if ("scan_times" in val.data) {
const [min, max] = d3.extent(val.data.scan_times);
if (max) {
const f = d3.format(".3f");
scan_times = {
data: "<small>" + f(min) + "/</small>" +
f(d3.mean(val.data.scan_times)) +
"<small>/" + f(max) + "</small>",
title: ' title="min/avg/max"'
};
} else {
scan_times = {
data: "-",
title: ' title="Have not scanned anything yet"'
};
}
}
}
}
$("#clusterTable tbody").append('<tr class="' + row_class + '">' +
'<td class="align-middle"><input type="radio" class="form-check m-auto" name="clusterName" value="' +
key + '"></td>' +
"<td>" + key + "</td>" +
"<td>" + val.host + "</td>" +
'<td class="text-center"><span class="icon"><i class="' + glyph_status + '"></i></span></td>' +
'<td class="text-center"' + scan_times.title + ">" + scan_times.data + "</td>" +
'<td class="text-end' +
((Number.isFinite(val.data.uptime) && val.data.uptime < 3600)
? ' warning" title="Has been restarted within the last hour"'
: "") +
'">' + uptime + "</td>" +
"<td>" + version + "</td>" +
"<td>" + short_id + "</td></tr>");
$("#selSrv").append($('<option value="' + key + '">' + key + "</option>"));
if (checked_server === key) {
$('#clusterTable tbody [value="' + key + '"]').prop("checked", true);
$('#selSrv [value="' + key + '"]').prop("selected", true);
} else if (!val.status) {
$('#clusterTable tbody [value="' + key + '"]').prop("disabled", true);
$('#selSrv [value="' + key + '"]').prop("disabled", true);
}
});
function addStatfiles(server, statfiles) {
const safeStatfiles = Array.isArray(statfiles) ? statfiles : [];
const classToSymbolClass = {spam: "symbol-positive", ham: "symbol-negative"};
const rowsCount = safeStatfiles.length;
function coerceNumber(value) { return (Number.isFinite(value) ? value : Number(value) || 0); }
function guessClassFromSymbol(symbol) {
if (!symbol) return "-";
const upperSymbol = symbol.toUpperCase();
if (upperSymbol.includes("SPAM")) return "spam";
if (upperSymbol.includes("HAM")) return "ham";
return "-";
}
function formatClassifierLabel(statfile) {
const classifier = statfile.classifier ?? {};
const badges = [];
function badge(cls, text) { return ` <span class="badge ${cls} ms-1">${text}</span>`; }
if (classifier.type === "multi-class") badges.push(badge("bg-secondary", "multi-class"));
if (classifier.per_user) badges.push(badge("bg-info", "per-user"));
return common.escapeHTML(classifier.name ?? "-") + badges.join("");
}
function renderCell(value, className) {
const cls = className?.trim();
return cls ? `<td class="${cls}">${value}</td>` : `<td>${value}</td>`;
}
$.each(safeStatfiles, (i, statfile) => {
const symbol = statfile.symbol ?? "-";
const classValue = statfile.class ?? guessClassFromSymbol(symbol);
const cls = classToSymbolClass[classValue] || "";
const clName = statfile.classifier?.name ?? "-";
const prevClName = i > 0 ? (safeStatfiles[i - 1].classifier?.name ?? "-") : null;
const serverCell = i === 0 ? `<td rowspan="${rowsCount}">${common.escapeHTML(server)}</td>` : "";
let classifierCell = "";
if (clName !== prevClName) {
let groupSize = 1;
for (let k = i + 1; k < safeStatfiles.length; k++) {
if ((safeStatfiles[k].classifier?.name ?? "-") === clName) {
groupSize++;
} else break;
}
classifierCell = `<td rowspan="${groupSize}">${formatClassifierLabel(statfile)}</td>`;
}
$("#bayesTable tbody").append(`<tr>${serverCell}${classifierCell}${[
renderCell(common.escapeHTML(classValue), cls),
renderCell(common.escapeHTML(symbol), cls),
renderCell(common.escapeHTML(statfile.type ?? "-"), cls),
renderCell(coerceNumber(statfile.revision), `text-end ${cls}`),
renderCell(coerceNumber(statfile.users), `text-end ${cls}`),
].join("")}</tr>`);
});
}
function addFuzzyStorage(server, storages) {
let i = 0;
$.each(storages, (storage, hashes) => {
const serverCell = (i === 0)
? '<td rowspan="' + Object.keys(storages).length + '">' + common.escapeHTML(server) + "</td>"
: "";
$("#fuzzyTable tbody").append("<tr>" + serverCell +
"<td>" + common.escapeHTML(storage) + "</td>" +
'<td class="text-end">' + hashes + "</td></tr>");
i++;
});
}
$("#bayesTable tbody, #fuzzyTable tbody").empty();
if (checked_server === "All SERVERS") {
$.each(servers, (server, val) => {
if (server !== "All SERVERS") {
addStatfiles(server, val.data.statfiles);
addFuzzyStorage(server, val.data.fuzzy_hashes);
}
});
} else {
addStatfiles(checked_server, data.statfiles);
addFuzzyStorage(checked_server, data.fuzzy_hashes);
}
if (!rowspanHoverHandlersInitialized) {
attachRowspanHoverHandlers("#bayesTable");
attachRowspanHoverHandlers("#fuzzyTable");
rowspanHoverHandlersInitialized = true;
}
}
function getChart(graphs, checked_server) {
if (!graphs.chart) {
graphs.chart = new D3Pie("chart", {
labels: {
inner: {
offset: 0
},
outer: {
collideHeight: 18,
}
},
size: {
pieInnerRadius: "50%"
},
title: "Rspamd filter stats",
total: {
enabled: true,
label: "Scanned"
}
});
}
const data = [];
const creds = JSON.parse(sessionStorage.getItem("Credentials") || "{}");
// Controller doesn't return the 'actions' object until at least one message is scanned
if (creds[checked_server]?.data?.scanned) {
const {actions} = creds[checked_server].data;
["no action", "soft reject", "add header", "rewrite subject", "greylist", "reject"]
.forEach((action) => {
data.push({
color: common.chartLegend.find((item) => item.label === action).color,
label: action,
value: actions[action]
});
});
}
graphs.chart.data(data);
}
// Public API
const ui = {
statWidgets: function (graphs, checked_server) {
common.query("stat", {
success: function (neighbours_status) {
const neighbours_sum = {
version: neighbours_status[0].data.version,
uptime: 0,
scanned: 0,
learned: 0,
actions: {
"no action": 0,
"add header": 0,
"rewrite subject": 0,
"greylist": 0,
"reject": 0,
"soft reject": 0,
}
};
let status_count = 0;
const promises = [];
const to_Credentials = {
"All SERVERS": {
name: "All SERVERS",
url: "",
host: "",
checked: true,
status: true
}
};
function process_node_stat(e) {
const {data} = neighbours_status[e];
// Controller doesn't return the 'actions' object until at least one message is scanned
if (data.scanned) {
for (const action in neighbours_sum.actions) {
if ({}.hasOwnProperty.call(neighbours_sum.actions, action)) {
neighbours_sum.actions[action] += data.actions[action];
}
}
}
["learned", "scanned", "uptime"].forEach((p) => {
neighbours_sum[p] += data[p];
});
status_count++;
}
// Get config_id, version and uptime using /auth query for Rspamd 2.5 and earlier
function get_legacy_stat(e) {
const alerted = "alerted_stats_legacy_" + neighbours_status[e].name;
promises.push($.ajax({
url: neighbours_status[e].url + "auth",
headers: {Password: common.getPassword()},
success: function (data) {
sessionStorage.removeItem(alerted);
["config_id", "version", "uptime"].forEach((p) => {
neighbours_status[e].data[p] = data[p];
});
process_node_stat(e);
},
error: function (jqXHR, textStatus, errorThrown) {
if (!(alerted in sessionStorage)) {
sessionStorage.setItem(alerted, true);
common.logError({
server: neighbours_status[e].name,
endpoint: "graph",
message: "Cannot receive legacy stats data" +
(errorThrown ? ": " + errorThrown : ""),
httpStatus: jqXHR.status,
errorType: "http_error"
});
}
process_node_stat(e);
}
}));
}
for (const e in neighbours_status) {
if ({}.hasOwnProperty.call(neighbours_status, e)) {
to_Credentials[neighbours_status[e].name] = neighbours_status[e];
if (neighbours_status[e].status === true) {
// Remove alert status
sessionStorage.removeItem("alerted_stats_" + neighbours_status[e].name);
if ({}.hasOwnProperty.call(neighbours_status[e].data, "version")) {
process_node_stat(e);
} else {
get_legacy_stat(e);
}
}
}
}
setTimeout(() => {
$.when.apply($, promises).always(() => {
neighbours_sum.uptime = Math.floor(neighbours_sum.uptime / status_count);
to_Credentials["All SERVERS"].data = neighbours_sum;
sessionStorage.setItem("Credentials", JSON.stringify(to_Credentials));
displayStatWidgets(checked_server);
getChart(graphs, checked_server);
});
}, promises.length ? 100 : 0);
},
complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); },
errorMessage: "Cannot receive stats data",
errorOnceId: "alerted_stats_",
server: "All SERVERS"
});
},
};
return ui;
}
);