%PDF- %PDF-
Direktori : /www/varak.net/dmarc.varak.net/public/js/ |
Current File : //www/varak.net/dmarc.varak.net/public/js/summary.js |
/** * dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports. * Copyright (C) 2022-2025 Aleksey Andreev (liuch) * * Available at: * https://github.com/liuch/dmarc-srg * * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, either version 3 of the License. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see <http://www.gnu.org/licenses/>. */ class Summary { constructor(id) { this._element = document.getElementById("main-block"); this._container = null; this._options_data = null; this._options_block = { main: null, domains: null, period: null, button1: null, button2: null }; this._report_block = null; } display() { this._create_container(); this._element.appendChild(this._container); this._create_options_block(); this._create_report_block(); this._container.appendChild(this._options_block.main); this._container.appendChild(document.createElement("hr")); this._container.appendChild(this._report_block); } update() { this._handle_url_params(); this._update_options_block(); this._fetch_report(); } title() { return "Summary Reports"; } _handle_url_params() { let url_params = new URL(document.location.href).searchParams; let domain = url_params.get("domain"); let period = url_params.get("period"); let format = url_params.get("format"); if (domain && period) { this._options_data = { domains: domain, period: period, format: format || "text" }; if (period.startsWith("range:")) { this._options_data.range = period.substring(6).split("-").map(v => { return v.replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3"); }); this._options_data.period = "range"; } } else { this._options_data = null; } } _create_container() { this._container = document.createElement("div"); this._container.setAttribute("class", "panel-container round-border"); } _create_options_block() { const main = document.createElement("div"); main.classList.add("options-block"); main.appendChild(document.createElement("h2")).textContent = "Report options:"; const list = main.appendChild(document.createElement("ul")); [ [ "period" ], [ "format" ], [ "domains", "fqdn" ] ].forEach(it => { const name = it[0]; const li = list.appendChild(document.createElement("li")); li.appendChild(document.createElement("span")).textContent = `${name}: `; const val = document.createElement("span"); li.appendChild(val).textContent = "none"; if (it[1]) val.classList.add(it[1]); this._options_block[name] = val; }); const bb = main.appendChild(document.createElement("div")); bb.classList.add("buttons-block"); const btn1 = bb.appendChild(document.createElement("button")); btn1.classList.add("options-button"); btn1.textContent = "Change the options"; btn1.addEventListener("click", event => this._display_dialog()); this._options_block.button1 = btn1; const btn2 = bb.appendChild(document.createElement("button")); btn2.classList.add("options-button", "hidden"); btn2.textContent = "Save CSV data to a file"; btn2.addEventListener("click", event => { const data = Array.from(this._report_block.querySelectorAll("pre")).map(el => el.textContent); const blob = new Blob(data, { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "DMARC summary report.csv"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 0); }); this._options_block.button2 = btn2; this._options_block.main = main; } _update_options_block() { [ "period", "format" ].forEach(id => { this._options_block[id].textContent = this._options_data && this._options_data[id] || "none"; }); if (this._options_data && this._options_data.period === "range") { this._options_block.period.append(` [ ${this._options_data.range.join(" - ")} ]`); } const de = this._options_block.domains; const dl = this._options_data && this._options_data.domains && this._options_data.domains.split(",") || [ "none" ]; de.replaceChildren(dl.slice(0, 3).join(", ")); if (dl.length > 3) { const dm = document.createElement("a"); dm.href = "#"; dm.textContent = `and ${dl.length - 3} more`; dm.addEventListener("click", event => { event.preventDefault(); de.replaceChildren(dl.join(", ")); }); de.append(" ", dm); } } _create_report_block() { this._report_block = document.createElement("div"); this._report_block.setAttribute("class", "summary-report"); } _display_dialog() { let dlg = new OptionsDialog(this._options_data); dlg.element().classList.add("report-dialog"); document.getElementById("main-block").appendChild(dlg.element()); dlg.show().then(d => { if (!d) return; const url = new URL(document.location.href); url.searchParams.set("domain", d.domain); let period = d.period; if (period === "lastndays") { period += ":" + d.days; } else if (period === "range") { period += ":" + d.range.map(val => val.replaceAll("-", "")).join("-"); } url.searchParams.set("period", period); url.searchParams.set("format", d.format); window.history.replaceState(null, "", url.toString()); this._element.replaceChildren(); this.display(); this.update(); }).finally(() => { dlg.element().remove(); this._options_block.button1.focus(); }); } _fetch_report() { this._report_block.replaceChildren(); if (!this._options_data) { this._report_block.appendChild(document.createTextNode("Report options are not selected")); return; } this._report_block.appendChild(set_wait_status()); const url = new URL("summary.php", document.location); url.searchParams.set("mode", "report"); url.searchParams.set("domain", this._options_data.domains); let period = this._options_data.period; if (period === "range") { period += ":" + this._options_data.range.map(r => r.replaceAll("-", "")).join("-"); } url.searchParams.set("period", period); let format = null; switch (this._options_data.format) { case "html": format = "raw"; break; case "csv": format = "csv"; break; default: format = "text"; break; } url.searchParams.set("format", format); window.fetch(url, { method: "GET", cache: "no-store", headers: HTTP_HEADERS, credentials: "same-origin" }).then(resp => { if (!resp.ok) throw new Error("Failed to fetch the report"); return resp.json(); }).then(data => { Common.checkResult(data); const overall = data.overall && (new OverallReport(data.overall)) || null; this._display_report(data.reports.map(rep => new SummaryReport(format, rep)), overall); }).catch(err => { Common.displayError(err); set_error_status(this._report_block, 'Error: ' + err.message); }).finally(() => { let wm = this._report_block.querySelector(".wait-message"); if (wm) wm.remove(); }); } _display_report(reports, overall) { function noDataElement() { const el = document.createElement("p"); el.append("No data"); return el; } function appendText(el, text) { el.appendChild(document.createElement("pre")).textContent = text; } function appendSeparator(el, html) { if (html) { el.append(document.createElement("hr")); } else { appendText(el, "==========\n"); } } let el = null; if (reports.length) { el = document.createDocumentFragment(); reports.forEach((report, index) => { switch (report.format) { case "raw": if (!index) { if (overall) { el.append(overall.html()); appendSeparator(el, true); } } else { appendSeparator(el, true); } el.append(report.html() || noDataElement()); break; case "text": { if (!index) { if (overall) { appendText(el, overall.text()); appendSeparator(el, false); } } else { appendSeparator(el, false); } const text = report.text(); if (text) { appendText(el, text); } else { el.append(noDataElement()); } } break; case "csv": if (!index && overall) appendText(el, overall.csv()); appendText(el, report.csv()); break; } }); } else { el = noDataElement(); } this._report_block.appendChild(el); if (this._options_data && this._options_data.format === "csv") { this._options_block.button2.classList.remove("hidden"); } else { this._options_block.button2.classList.add("hidden"); } } } class OptionsDialog extends VerticalDialog { constructor(params) { super({ title: "Report options", buttons: [ "apply", "reset" ] }); this._data = params || {}; this._content = null; this._domains = null; this._ui_data = [ { name: "domains", title: "Domains", type: "multi-select" }, { name: "period", title: "Period" }, { name: "days", title: "Days", type: "input" }, { name: "range", title: "Range", type: "div" }, { name: "format", title: "Format" } ]; } _gen_content() { this._ui_data.forEach(row => { const i_el = this._insert_input_row(row.title, row.name, row.type); const name = row.name; if (name === "domains") { i_el.setLabel("Domains"); } else if (name === "days") { i_el.setAttribute("type", "number"); i_el.setAttribute("min", "1"); i_el.setAttribute("max", "9999"); i_el.setAttribute("value", ""); } else if (name === "range") { const r1 = i_el.appendChild(document.createElement("input")); r1.type = "date"; r1.name = "date1"; i_el.append(" - "); const r2 = i_el.appendChild(document.createElement("input")); r2.type = "date"; r2.name = "date2"; } row.element = i_el; }); this._ui_data[0].element.setAttribute("placeholder", "Pick domains"); this._ui_data[0].element.addEventListener("change", event => { this._buttons[1].disabled = this._ui_data[0].element.isEmpty(); }); this._ui_data[1].element.addEventListener("change", event => { const day_el = this._ui_data[2].element; const per_el = this._ui_data[3].element; const period = event.target.value; if (period === "range") { day_el.parentElement.classList.add("hidden"); per_el.parentElement.classList.remove("hidden"); per_el.querySelectorAll("input").forEach(inp => inp.required = true); } else { per_el.parentElement.classList.add("hidden"); day_el.parentElement.classList.remove("hidden"); per_el.querySelectorAll("input").forEach(inp => inp.required = false); } if (period === "lastndays") { day_el.disabled = false; delete day_el.dataset.disabled; day_el.value = day_el.dataset.value || "1"; } else { day_el.disabled = true; day_el.dataset.value = day_el.value || "1"; day_el.dataset.disabled = true; day_el.value = ""; } }); this._update_period_element(); this._update_format_element(); if (!this._domains) { this._fetch_data(); } } _insert_input_row(text, name, type) { let el = document.createElement(type || "select"); el.setAttribute("name", name); super._insert_input_row(text, el); return el; } _submit() { let res = { domain: this._ui_data[0].element.getValues().join(","), period: this._ui_data[1].element.value, format: this._ui_data[4].element.value }; switch (res.period) { case "lastndays": res.days = parseInt(this._ui_data[2].element.value) || 1; break; case "range": res.range = Array.from(this._ui_data[3].element.querySelectorAll("input")).map(inp => inp.value); if (Date.UTC(...res.range[0].split("-")) > Date.UTC(...res.range[1].split("-"))) { Notification.add({ text: "Incorrect date range", type: "error" }); this._ui_data[3].element.querySelector("input").focus(); return; } break; } this._result = res; this.hide(); } _reset() { this._ui_data[0].element.setValues(this._data.domains && this._data.domains.split(",") || []); window.setTimeout(() => this._ui_data[1].element.dispatchEvent(new Event("change")), 0); } _update_domain_element() { let el = this._ui_data[0].element; el.clear(); if (this._domains) { this._domains.forEach(name => el.appendItem(name)); } if (this._data.domains) el.setValues(this._data.domains.split(",")); } _update_period_element() { let el = this._ui_data[1].element; let c_val = this._data.period && this._data.period.split(":") || [ "lastweek" ]; [ [ "lastweek", "Last week"], [ "lastmonth", "Last month" ], [ "lastndays", "Last N days" ], [ "range", "Date range"] ].forEach(it => { let opt = document.createElement("option"); opt.setAttribute("value", it[0]); if (it[0] === c_val[0]) { opt.setAttribute("selected", ""); } el.appendChild(opt).textContent = it[1]; }); if (c_val[1]) { let val = parseInt(c_val[1]); let i_el = this._ui_data[2].element; i_el.setAttribute("value", val); i_el.dataset.value = val; } if (this._data.range) { this._ui_data[3].element.querySelectorAll("input").forEach((el, idx) => { let rv = this._data.range[idx]; if (rv) el.setAttribute("value", rv); }); } el.dispatchEvent(new Event("change")); } _update_format_element() { let el = this._ui_data[4].element; let cv = this._data.format || "text"; [ [ "text", "Plain text" ], [ "html", "HTML" ], [ "csv", "CSV data" ] ].forEach(it => { let opt = document.createElement("option"); opt.setAttribute("value", it[0]); if (it[0] === cv) { opt.setAttribute("selected", ""); } el.appendChild(opt).textContent = it[1]; }); } _enable_ui(enable) { const dom_el = this._ui_data[0].element; const controls = Array.from(this._element.querySelector("form").elements); controls.push(dom_el); for (const el of controls) { if (el.type === "submit") { el.disabled = !enable || dom_el.isEmpty(); } else { el.disabled = !enable || el.dataset.disabled; } } this.focus(); } _fetch_data() { this._enable_ui(false); this.display_status("wait", "Getting data..."); window.fetch("summary.php?mode=options", { method: "GET", cache: "no-store", headers: HTTP_HEADERS, credentials: "same-origin" }).then(resp => { if (!resp.ok) throw new Error("Failed to fetch the report options list"); return resp.json(); }).then(data => { Common.checkResult(data); this._domains = data.domains; this._update_domain_element(); this._enable_ui(true); }).catch(err => { Common.displayError(err); this.display_status("error", err.message); }).finally(() => { this.display_status("wait", null); }); } } class SummaryReport { constructor(format, data) { this.format = format; this._report = data; } text() { let lines = this._report.text || []; if (lines.length > 0) { return lines.join("\n"); } } csv() { return this._report.csv || ""; } html() { const data = this._report.data; const html = document.createDocumentFragment(); html.appendChild(document.createElement("h2")).textContent = "Domain: " + this._report.domain; html.appendChild(document.createElement("div")).append( "Range: ", (new Date(data.date_range.begin)).toUIDateString(true), " - ", (new Date(data.date_range.end)).toUIDateString(true) ); { html.appendChild(document.createElement("h3")).textContent = "Summary"; const cont = html.appendChild(document.createElement("div")); cont.classList.add("left-titled"); const emails = data.summary.emails; const total = emails.total; cont.append(SummaryReport.makeSummaryRow("Total", total, null, null)); const f_aligned = emails.dkim_spf_aligned; const p_aligned = emails.dkim_aligned + emails.spf_aligned; const n_aligned = total - f_aligned - p_aligned; const rejected = emails.rejected; const quarantined = emails.quarantined; [ [ "Fully aligned", f_aligned, "pass" ], [ "Partial aligned", p_aligned, null ], [ "Not aligned", n_aligned, "fail" ], [ "Quarantined", quarantined, "fail" ], [ "Rejected", rejected, "fail" ] ].forEach(it => cont.append(SummaryReport.makeSummaryRow(it[0], it[1], total, it[2]))); } if (data.sources && data.sources.length) { html.appendChild(document.createElement("h3")).textContent = "Sources"; const table = html.appendChild(document.createElement("table")); table.classList.add("report-table"); table.appendChild(document.createElement("caption")).textContent = "Total records: " + data.sources.length; const thead = document.createElement("thead"); table.appendChild(thead); thead.append(SummaryReport.makeHeaderRow([ [ "IP address", 0, 2 ], [ "Email volume", 0, 2 ], [ "Partial aligned", 2, 0 ], [ "Not aligned", 0, 2 ], [ "Disposition", 2, 0 ] ])); thead.append(SummaryReport.makeHeaderRow( [ [ "SPF only" ], [ "DKIM only" ], [ "quar+rej" ], [ "fail rate" ] ] )); const tbody = table.appendChild(document.createElement("tbody")); data.sources.forEach(sou => { const tr = tbody.appendChild(document.createElement("tr")); [ Common.makeIpElement(sou.ip), sou.emails, sou.spf_aligned, sou.dkim_aligned ].forEach( v => tr.append(SummaryReport.makeResultElement("td", v, null)) ); const dq = sou.quarantined; const dr = sou.rejected; const ds = dq || dr ? `${dq.toLocaleString()}+${dr.toLocaleString()}` : 0; [ sou.emails - sou.dkim_aligned - sou.spf_aligned - sou.dkim_spf_aligned, ds ].forEach(v => tr.append(SummaryReport.makeResultElement("td", v, "fail"))); tr.append(SummaryReport.makeResultElement("td", SummaryReport.num2percent(dq + dr, sou.emails, false))); }); } if (data.organizations && data.organizations.length) { html.appendChild(document.createElement("h3")).textContent = "Reporting organizations"; const table = html.appendChild(document.createElement("table")); table.classList.add("report-table"); table.appendChild(document.createElement("caption")).textContent = "Total records: " + data.organizations.length; const thead = table.appendChild(document.createElement("thead")); thead.append(SummaryReport.makeHeaderRow([ [ "Name", 0, 2 ], [ "Volume", 2, 0 ], [ "Partial aligned", 2, 0 ], [ "Not aligned", 0, 2 ], [ "Disposition", 2, 0 ] ])); thead.append(SummaryReport.makeHeaderRow([ [ "reports" ], [ "emails" ], [ "SPF only" ], [ "DKIM only" ], [ "quar+rej" ], [ "fail rate" ] ])); const tbody = table.appendChild(document.createElement("tbody")); data.organizations.forEach(org => { const tr = tbody.appendChild(document.createElement("tr")); [ org.name, org.reports, org.emails, org.spf_aligned, org.dkim_aligned ].forEach(v => tr.append(SummaryReport.makeResultElement("td", v))); const dq = org.quarantined; const dr = org.rejected; const ds = dq || dr ? `${dq.toLocaleString()}+${dr.toLocaleString()}` : 0; [ org.emails - org.dkim_aligned - org.spf_aligned - org.dkim_spf_aligned, ds ].forEach(v => tr.append(SummaryReport.makeResultElement("td", v, "fail"))); tr.append(SummaryReport.makeResultElement("td", SummaryReport.num2percent(dq + dr, org.emails, false))); }); } return html; } static num2percent(per, cent, with_num) { if (!per) return 0; let res = "" + Math.round(per / cent * 100) + "%"; if (with_num) res += " (" + per + ")"; return res; } static makeSummaryRow(title, value, total, type) { const re = document.createDocumentFragment(); const te = re.appendChild(document.createElement("span")); te.textContent = title + ": "; if (total) { re.append(SummaryReport.makeResultElement("span", SummaryReport.num2percent(value, total, true), type)); } else { re.append(value); } return re; } static makeHeaderRow(data) { const tr = document.createElement("tr"); data.forEach(row => { const td = tr.appendChild(document.createElement("th")); if (row[1]) td.colSpan = row[1]; if (row[2]) td.rowSpan = row[2]; td.textContent = row[0]; }); return tr; } static makeResultElement(name, value, type) { const el = document.createElement(name); if (value && type) el.classList.add("report-result-" + type); el.append(typeof(value) === "number" ? value.toLocaleString() : value); return el; } } class OverallReport extends SummaryReport { constructor(data) { super("overall", data); } html() { const html = document.createDocumentFragment(); html.appendChild(document.createElement("h2")).textContent = "Overall by domains"; const table = html.appendChild(document.createElement("table")); table.classList.add("report-table"); table.appendChild(document.createElement("caption")).textContent = "Total records: " + this._report.data.length; const thead = table.appendChild(document.createElement("thead")); thead.append(SummaryReport.makeHeaderRow([ [ "Name", 0, 2 ], [ "Emails", 0, 2 ], [ "Partial aligned", 2, 0 ], [ "Not aligned", 0, 2 ], [ "Disposition", 2, 0 ] ])); thead.append(SummaryReport.makeHeaderRow([ [ "SPF only" ], [ "DKIM only" ], [ "quar+rej" ], [ "fail rate" ] ])); const tbody = table.appendChild(document.createElement("tbody")); this._report.data.forEach(d => { const tr = tbody.appendChild(document.createElement("tr")); [ d.fqdn, d.total, d.spf_aligned, d.dkim_aligned ].forEach(v => tr.append(SummaryReport.makeResultElement("td", v))); const dq = d.quarantined; const dr = d.rejected; const ds = (dq || dr) ? `${dq.toLocaleString()}+${dr.toLocaleString()}` : 0; [ d.total - d.dkim_aligned - d.spf_aligned - d.dkim_spf_aligned, ds ].forEach(v => tr.append(SummaryReport.makeResultElement("td", v, "fail"))); tr.append(SummaryReport.makeResultElement("td", SummaryReport.num2percent(dq + dr, d.total, false))); }); return html; } }