New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.
Docs, plan and summary in docs/changes/.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
108 lines
4.8 KiB
JavaScript
108 lines
4.8 KiB
JavaScript
/* The address-list dialog of the analytics pages (markup: the "analytics_dialog"
|
|
template) and the tooltip that goes with it. A page loads a list with
|
|
AnalyticsDialog.load(url, title), then shows it with AnalyticsDialog.fill(m);
|
|
copying and the CSV download work on the list shown last. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
|
|
function esc(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
|
|
|
|
var cur = null; // the list shown in the dialog
|
|
|
|
function csvText(cols, rows) {
|
|
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
|
|
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
|
|
}
|
|
|
|
function open() {
|
|
var d = $('an-dlg');
|
|
if (d.open) return;
|
|
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
|
}
|
|
|
|
/* m: title, note, scope ("Запуск" by default), runLabel, cols, rows, hints
|
|
(column index -> text), cellHints (column index -> function of the value),
|
|
dist (HTML above the table), csvURL (the file behind "Скачать CSV"). */
|
|
function fill(m) {
|
|
cur = m;
|
|
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
|
|
$('an-dlg-note').textContent = (m.scope || 'Запуск') + ': ' + m.runLabel + '. ' + m.note;
|
|
$('an-dlg-dist').hidden = !m.dist;
|
|
$('an-dlg-dist').innerHTML = m.dist || '';
|
|
var hint = m.hints || {};
|
|
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
|
|
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
|
|
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
|
|
return '<tr>' + x.map(function (v, i) {
|
|
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
|
|
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
|
|
}).join('') + '</tr>';
|
|
}).join('') + '</tbody>';
|
|
$('an-dlg-msg').textContent = '';
|
|
}
|
|
|
|
/* Opens the dialog with the title and "Загрузка…", fetches the list as JSON;
|
|
resolves with it, or with null (and a message in the dialog) on a failure. */
|
|
function load(url, title) {
|
|
$('an-dlg-title').textContent = title;
|
|
$('an-dlg-note').textContent = 'Загрузка…';
|
|
$('an-dlg-dist').hidden = true;
|
|
$('an-dlg-tbl').innerHTML = '';
|
|
$('an-dlg-msg').textContent = '';
|
|
cur = null;
|
|
open();
|
|
return fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(function (l) { return l; })
|
|
.catch(function (e) {
|
|
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function copyCsv() {
|
|
var msg = $('an-dlg-msg');
|
|
if (!cur) return;
|
|
var text = csvText(cur.cols, cur.rows);
|
|
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
|
|
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
|
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
|
|
}
|
|
|
|
function downloadCsv() {
|
|
if (!cur) return;
|
|
window.location.href = cur.csvURL;
|
|
}
|
|
|
|
$('an-dlg-csv').addEventListener('click', downloadCsv);
|
|
$('an-dlg-copy').addEventListener('click', copyCsv);
|
|
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
|
|
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
|
|
|
|
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
|
|
var tip = $('an-tip');
|
|
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
|
|
document.addEventListener('mouseover', function (e) {
|
|
var t = e.target.closest('[data-tip]');
|
|
if (!t) return;
|
|
var h = tipHost();
|
|
if (tip.parentNode !== h) h.appendChild(tip);
|
|
tip.textContent = t.dataset.tip;
|
|
tip.style.opacity = 1;
|
|
});
|
|
document.addEventListener('mousemove', function (e) {
|
|
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
|
|
tip.style.top = (e.clientY + 16) + 'px';
|
|
});
|
|
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
|
|
|
|
window.AnalyticsDialog = { $: $, fmt: fmt, esc: esc, tipAttr: tipAttr, load: load, fill: fill };
|
|
})();
|