Analytics: compare two finished runs

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>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-04 10:26:37 +03:00
1 parent 2f038f8362
commit 068c10ea1c
28 files changed
+2057 -138

No files matched your search

@@ -0,0 +1,136 @@
/* Comparison of two finished runs: renders the comparison embedded as JSON in
#analytics-data (the groups of addresses, the dynamics of the seven
indicators, the verdict transitions) and opens the address list behind each
number in the shared dialog (analytics-dialog.js). The state of the page is
its address: ?base=A&target=B. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, G = R.groups, T = R.transitions;
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
var NAME = {};
R.indicators.forEach(function (d) { NAME[d.key] = d.name; });
/* ---- what each list says about itself ---- */
var GROUP = {
new: ['Новые адреса', 'Есть в запуске B, в A их не было: прибыли в проект и подключились к анализу. Столбцы показывают состояние в B.'],
left: ['Выбывшие адреса', 'Были в запуске A, в B их нет: вышли из состава проекта. Столбцы показывают состояние в A.'],
common: ['Общие адреса', 'Есть в обоих запусках. Столбцы показывают состояние в A → B.'],
changed: ['Изменившиеся адреса', 'Есть в обоих запусках, принадлежность хотя бы к одному из семи индикаторов в A и в B разная.'],
same: ['Адреса без изменений', 'Есть в обоих запусках, принадлежность ко всем индикаторам та же. Другой набор проваленных целей или площадок изменением не считается.'],
entered: ['Вошли в индикатор', 'Общие адреса, которые в A не входили в этот индикатор, а в B входят.'],
exited: ['Вышли из индикатора', 'Общие адреса, которые в A входили в этот индикатор, а в B не входят.']
};
var HINT_ONE = {
2: 'Вердикт адреса, который система выставила при агрегации, а не итог всех проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — ingress-проверок нет.',
5: 'Индикаторы страницы «Аналитика», в которые входит адрес. Один адрес может входить в несколько; вердикт — ровно в один из трёх.'
};
var HINT_PAIR = {
2: 'Вердикт адреса в запуске A и в запуске B.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
5: 'Что изменилось между запусками, по шагам: вердикт, вход в индикатор и выход из него, проваленные цели https и площадки ssh, валидатор. У адреса без изменений в индикаторах — «без изменений», даже если набор проваленных целей другой.'
};
function describe(f) {
var g = GROUP[f.group], ind = f.ind ? '«' + (NAME[f.ind] || f.ind) + '»' : '';
var title = g[0] + (f.ind ? ': ' + (NAME[f.ind] || f.ind) : '') + (f.from ? ' · вердикт ' + f.from + ' → ' + f.to : '');
var note = g[1];
if (f.ind && f.group === 'new') note += ' Только входящие в индикатор ' + ind + ' в запуске B.';
if (f.ind && f.group === 'left') note += ' Только входившие в индикатор ' + ind + ' в запуске A.';
if (f.ind && (f.group === 'common' || f.group === 'changed' || f.group === 'same')) note += ' Только входящие в индикатор ' + ind + ' хотя бы в одном из запусков.';
if (f.from) note += ' Только адреса с вердиктом ' + f.from + ' в A и ' + f.to + ' в B.';
return { title: title, note: note, hints: f.group === 'new' || f.group === 'left' ? HINT_ONE : HINT_PAIR };
}
function listURL(root, f) {
return root + encodeURIComponent(f.group) + '?base=' + M.base.id + '&target=' + M.target.id +
(f.ind ? '&indicator=' + encodeURIComponent(f.ind) : '') +
(f.from ? '&from=' + encodeURIComponent(f.from) + '&to=' + encodeURIComponent(f.to) : '');
}
function openList(f) {
var t = describe(f);
A.load(listURL(M.list_url, f), t.title).then(function (l) {
if (!l) return;
A.fill({
title: t.title, note: t.note, hints: t.hints, scope: 'Запуски', runLabel: 'A — ' + M.base.label + '; B — ' + M.target.label,
cols: l.columns, rows: l.rows, csvURL: listURL(M.csv_url, f)
});
});
}
/* ---- the page ---- */
function renderNote() {
function line(name, m, r, cancelled) {
return '<div>' + esc(name + ': запуск ' + m.id + ', ' + m.kind + ', начало ' + m.start + ', завершён ' + m.end + ', длительность ' + m.duration +
', ' + fmt(r.addresses) + ' адр.' + (r.rechecked ? ', перепроверено внутри запуска: ' + fmt(r.rechecked) : '') +
(cancelled ? ', отменено (не участвуют в сравнении): ' + fmt(cancelled) : '') + '.') + '</div>';
}
$('an-cmp-note').innerHTML = line('A', M.base, R.runs.base, R.cancelled.base) + line('B', M.target, R.runs.target, R.cancelled.target);
}
function renderKpis() {
var GO = ' <span class="an-go">список →</span>';
var tiles = [
['Новые', G.new, 'есть в B, не было в A', 'new'],
['Выбывшие', G.left, 'были в A, нет в B', 'left'],
['Общие', G.common, 'есть в обоих запусках', 'common'],
['Изменились', G.changed, pct(G.changed, G.common) + '% общих адресов', 'changed'],
['Без изменений', G.same, pct(G.same, G.common) + '% общих адресов', 'same']
];
$('an-cmp-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + fmt(t[1]) + '</div><div class="an-d">' + t[2] + (t[1] ? GO : '') + '</div>';
return t[1]
? '<button type="button" class="an-kpi" data-group="' + t[3] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
/* a number that opens a list; zero is plain text */
function link(n, attrs, tip) {
return n ? '<button type="button" class="an-lnk" ' + attrs + ' ' + tipAttr(tip) + '>' + fmt(n) + '</button>' : '<span class="an-zero">0</span>';
}
function renderIndicators() {
var COLS = [['new', 'Новые', 'новые адреса, входящие в индикатор'], ['left', 'Выбывшие', 'выбывшие адреса, входившие в индикатор'],
['entered', 'Вошли', 'общие адреса, вошедшие в индикатор'], ['exited', 'Вышли', 'общие адреса, вышедшие из индикатора']];
$('an-cmp-ind').innerHTML = '<thead><tr><th>Индикатор</th><th class="an-r">A</th><th class="an-r">B</th><th class="an-r">Δ</th>' +
COLS.map(function (c) { return '<th class="an-r">' + c[1] + '</th>'; }).join('') + '</tr></thead><tbody>' +
R.indicators.map(function (d) {
var good = d.key === 'verdict_pass' ? d.delta > 0 : d.delta < 0;
var delta = d.delta > 0 ? '+' + fmt(d.delta) : d.delta < 0 ? '−' + fmt(-d.delta) : '0';
return '<tr><td>' + esc(d.name) + '</td><td class="an-num an-r">' + fmt(d.base) + '</td><td class="an-num an-r">' + fmt(d.target) + '</td>' +
'<td class="an-num an-r' + (d.delta ? (good ? ' an-good' : ' an-bad') : '') + '">' + delta + '</td>' +
COLS.map(function (c) {
return '<td class="an-num an-r">' + link(d[c[0]], 'data-group="' + c[0] + '" data-ind="' + d.key + '"', d.name + ': ' + c[2]) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderTransitions() {
var V = T.verdicts;
$('an-cmp-tr').innerHTML = '<thead><tr><th></th>' + V.map(function (v) { return '<th class="an-r">B: ' + esc(v) + '</th>'; }).join('') + '<th class="an-r">нет в B</th></tr></thead><tbody>' +
V.map(function (from, i) {
return '<tr><td>A: ' + esc(from) + '</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.matrix[i][j], 'data-group="common" data-from="' + from + '" data-to="' + to + '"', 'вердикт ' + from + ' в A → ' + to + ' в B') + '</td>';
}).join('') + '<td class="an-num an-r">' + link(T.left[i], 'data-group="left" data-ind="verdict_' + from + '"', 'выбыли, вердикт в A: ' + from) + '</td></tr>';
}).join('') +
'<tr><td>нет в A</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.new[j], 'data-group="new" data-ind="verdict_' + to + '"', 'новые, вердикт в B: ' + to) + '</td>';
}).join('') + '<td class="an-num an-r">—</td></tr></tbody>';
}
/* ---- wiring ---- */
function onOpen(e) {
var b = e.target.closest('[data-group]');
if (b) openList({ group: b.dataset.group, ind: b.dataset.ind || '', from: b.dataset.from || '', to: b.dataset.to || '' });
}
renderNote(); renderKpis(); renderIndicators(); renderTransitions();
['an-cmp-kpis', 'an-cmp-ind', 'an-cmp-tr'].forEach(function (id) { $(id).addEventListener('click', onOpen); });
})();