Files
cloud-ip-validator/internal/dashboard/static/analytics.js
T

280 lines
24 KiB
JavaScript
Raw Normal View History

/* Analytics page: renders the report of one finished run (embedded as JSON in
#analytics-data) and opens the address lists behind the indicators and the
2026-10-04 10:26:37 +03:00
error classes in the shared dialog (analytics-dialog.js). No other run's data
is on the page. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
2026-10-04 10:26:37 +03:00
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; }
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
var state = { sort: 'worst', all: false, type: R.targets.types.indexOf('https') >= 0 ? 'https' : (R.targets.types[0] || '') };
/* ---- indicators ---- */
function renderKpis() {
var late = Q.late_failed_checks_at_pass, GO = ' <span class="an-go">список →</span>';
var tiles = [
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>' + GO, 'verdict_pass'],
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>' + GO, 'verdict_partial'],
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
S.fail ? '<span class="an-tag an-t-bad">провал</span>' + GO : '<span class="an-tag an-t-n">нет</span>', S.fail ? 'verdict_fail' : ''],
['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''],
['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''],
['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна',
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'egress_https_any'],
['Ingress ssh: есть провалы', fmt(S.ingress_ssh_any_failed), pct1(S.ingress_ssh_any_failed, S.addresses) + '% адресов, хотя бы с одной площадки',
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_any'],
['Egress https: все провалены', fmt(S.egress_https_all_failed), pct1(S.egress_https_all_failed, S.addresses) + '% адресов, по всем целям: ' + fmt(S.egress_https_all_targets_failed),
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'egress_https_all'],
['Ingress ssh: все провалены', fmt(S.ingress_ssh_all_failed), pct1(S.ingress_ssh_all_failed, S.addresses) + '% адресов, ssh провален со всех площадок',
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_all'],
['Длительность', esc(M.duration), fmt(S.addresses_per_minute) + ' адр./мин', '', ''],
['Поздние результаты', fmt(late), 'у ' + fmt(Q.late_failed_addresses_at_pass) + ' адресов, после вердикта',
late ? '<span class="an-tag an-t-warn">внимание</span>' : '<span class="an-tag an-t-ok">в норме</span>', '']
];
$('an-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + t[1] + '</div><div class="an-d">' + t[2] + ' ' + t[3] + '</div>';
return t[4]
? '<button type="button" class="an-kpi" data-list="' + t[4] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
function bar(v, max) {
return '<div class="an-track"><div class="an-fill" style="width:' + (max ? Math.max(1, v / max * 100) : 0) + '%"></div></div>';
}
function renderReasons() {
var max = Math.max.apply(null, R.reasons.map(function (x) { return x.count; }).concat([1]));
$('an-reasons').innerHTML = R.reasons.map(function (x) {
return '<div class="an-bar-row" ' + tipAttr(x.name + ': ' + fmt(x.count) + ' адр. (' + pct(x.count, S.partial) + '% от partial)') + '><span class="an-n">' + esc(x.name) + '</span>' + bar(x.count, max) + '<span class="an-num">' + fmt(x.count) + '</span></div>';
}).join('');
}
function renderQuality() {
var errTotal = Q.ingress_failed_checks;
var rows = [
['Поздние провалы у адресов с вердиктом pass', fmt(Q.late_failed_checks_at_pass) + ' / ' + fmt(Q.late_failed_addresses_at_pass) + ' адр.'],
['Провалы ingress, пришедшие после вердикта', fmt(Q.ingress_failed_late) + ' из ' + fmt(errTotal)],
['Неполный набор проверок', fmt(Q.incomplete_addresses) + ' адр.'],
['Вердикт pass, но есть проваленные проверки', fmt(Q.pass_with_failed_addresses) + ' адр.'],
['pass по вердикту → по фактическим проверкам', fmt(S.pass) + ' → ' + fmt(Q.pass_by_facts)]
];
$('an-dq').innerHTML = rows.map(function (r) { return '<div class="an-row"><span>' + r[0] + '</span><span>' + r[1] + '</span></div>'; }).join('');
}
function pbar(a, n) {
var p = pct(a, n);
return '<div class="an-pb"><div class="an-track"><div class="an-fill" style="width:' + p + '%"></div></div><span class="an-num">' + p + '%</span></div>';
}
function renderSubnets() {
var list = R.subnets.slice();
list.sort(state.sort === 'worst'
? function (a, b) { return a.pass / a.addresses - b.pass / b.addresses || b.addresses - a.addresses; }
: function (a, b) { return b.addresses - a.addresses; });
var shown = state.all ? list : list.slice(0, 10);
$('an-allsub').textContent = state.all ? 'свернуть до 10' : 'показать все ' + list.length;
$('an-allsub').hidden = list.length <= 10;
$('an-subtbl').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">Адресов</th><th class="an-r">pass</th><th class="an-r">Egress OK</th><th class="an-r">Ingress OK</th></tr></thead><tbody>' +
shown.map(function (s) {
var name = s.label ? s.cidr + ' · ' + s.label : s.cidr;
var link = s.cidr === 'прочие' ? esc(name) : '<a href="' + M.registry_url + '&amp;subnet=' + encodeURIComponent(s.cidr) + '" title="Открыть в реестре: запуск ' + M.run_id + ', подсеть ' + esc(s.cidr) + '">' + esc(name) + '</a>';
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.pass, s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': egress OK ' + fmt(s.egress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.egress_ok, s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': ingress OK ' + fmt(s.ingress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.ingress_ok, s.addresses) + '</td></tr>';
}).join('') + '</tbody>';
}
function heatClass(v) { return 'an-h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
function renderTypes() {
$('an-types').innerHTML = R.targets.types.map(function (t) {
return '<button type="button" data-type="' + esc(t) + '" aria-pressed="' + (t === state.type) + '">' + esc(t) + '</button>';
}).join('');
}
function renderTargets() {
var t = state.type, T = R.targets, vals = T.failed[t] || [];
var max = Math.max.apply(null, vals.concat([1]));
$('an-targets').innerHTML = T.targets.map(function (n, i) {
return '<div class="an-bar-row" ' + tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], S.addresses) + '% всех)') + '><span class="an-n">' + esc(n) + '</span>' + bar(vals[i], max) + '<span class="an-num">' + fmt(vals[i]) + ' · ' + pct(vals[i], S.addresses) + '%</span></div>';
}).join('');
var rows = (R.matrix[t] || []).slice(0, state.all ? 100000 : 10);
$('an-matrixwrap').hidden = !rows.length;
$('an-hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map(function (l, i) {
return '<span><i style="background:var(--an-h' + (i + 1) + ')"></i> ' + l + '%</span>';
}).join('');
$('an-matrix').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">partial</th>' + T.targets.map(function (x) {
return '<th class="an-r">' + esc(x.replace('.com', '').replace('.org', '')) + '</th>';
}).join('') + '</tr></thead><tbody>' + rows.map(function (m) {
var link = m.cidr === 'прочие' ? esc(m.cidr) : '<a href="' + M.registry_url + '&amp;subnet=' + encodeURIComponent(m.cidr) + '" title="Открыть адреса в реестре">' + esc(m.cidr) + '</a>';
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(m.partial) + '</td>' + m.percent.map(function (v, j) {
return '<td class="an-c"><span class="an-cell ' + heatClass(v) + '" ' + tipAttr(m.cidr + ' → ' + T.targets[j] + ': ' + v + '% адресов partial провалили ' + t) + '>' + v + '%</span></td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-matrixnote').textContent = 'Доля адресов partial подсети, провалившие ' + t + ' к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
}
function renderSites() {
var T = R.sites;
$('an-sites').innerHTML = '<thead><tr><th>Площадка</th>' + T.types.map(function (t) { return '<th class="an-r">' + esc(t) + '</th>'; }).join('') + '</tr></thead><tbody>' +
T.rows.map(function (row) {
return '<tr><td class="an-a">' + esc(row.site) + '</td>' + row.stats.map(function (st, i) {
return '<td class="an-r" ' + tipAttr(row.site + ' · ' + T.types[i] + ': успешно ' + fmt(st.ok) + ' из ' + fmt(st.total)) + '><span class="an-num">' + pct1(st.total - st.ok, st.total) + '%</span> <span class="an-note">провал</span></td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderErrors() {
var max = Math.max.apply(null, R.errors.map(function (e) { return e.count; }).concat([1]));
$('an-errs').innerHTML = R.errors.length ? R.errors.map(function (e) {
return '<button type="button" class="an-bar-row" data-cls="' + esc(e.name) + '" aria-haspopup="dialog" ' + tipAttr(e.name + ': ' + fmt(e.count) + ' проверок. Нажмите, чтобы открыть список') + '><span class="an-n">' + esc(e.name) + '</span>' + bar(e.count, max) + '<span class="an-num">' + fmt(e.count) + '</span></button>';
}).join('') : '<p class="an-note">Проваленных ingress-проверок нет.</p>';
}
function renderValidators() {
var V = R.validators;
var f = V.map(function (v) { return v.total ? 1 - v.ok / v.total : 0; });
var avg = f.length ? f.reduce(function (a, b) { return a + b; }, 0) / f.length : 0;
$('an-vals').innerHTML = f.map(function (x, i) {
return '<div class="an-col" style="height:' + (x * 100) + '%" ' + tipAttr(vshort(V[i].validator) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(V[i].total) + ' https-проверок') + '></div>';
}).join('') + '<div class="an-avg" style="bottom:' + (avg * 100) + '%"></div>';
$('an-vfirst').textContent = V.length ? 'валидатор ' + vshort(V[0].validator) : '';
$('an-vlast').textContent = V.length > 1 ? 'валидатор ' + vshort(V[V.length - 1].validator) : '';
$('an-vavg').textContent = V.length ? 'среднее ' + Math.round(avg * 100) + '% (шкала 0–100%)' : 'нет данных';
}
/* ---- address lists in the dialog ---- */
var VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
var STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
var VERDICT_VAL = {
pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.',
partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.',
fail: 'fail: все проверки адреса провалены.'
};
function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; }
var VERDICT_NOTE = ' Это вердикт адреса, который система выставила при агрегации, а не итог всех проверок: после него могли прийти ещё результаты, поэтому у адреса pass бывают проваленные проверки. Столбцы Egress и Ingress показывают фактические проверки адреса.';
var VERDICT_HINTS = {
2: 'Валидатор, с которого шли egress-проверки адреса. «—» — у адреса нет https-проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в цикле. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в цикле (со всех площадок и всех типов). «—» — ingress-проверок нет.',
5: 'Сколько проверок записано в цикле из ожидаемых. Меньше ожидаемого значит, что часть результатов не пришла (неполный набор). Если ожидаемое число неизвестно, показано только записанное.'
};
function verdictList(title, note, reason) {
var hints = {};
Object.keys(VERDICT_HINTS).forEach(function (k) { hints[k] = VERDICT_HINTS[k]; });
if (reason) hints[6] = 'Что не так у адреса: провалены egress-проверки, ingress-проверки или не хватает результатов (неполный набор). Те же названия, что в блоке «Почему partial».';
return { title: title, note: note + VERDICT_NOTE, hints: hints };
}
var LISTS = {
verdict_pass: verdictList('Адреса с вердиктом pass',
'Вердикт pass: адрес за последний цикл запуска получил успех по всем проверкам, полученным к моменту агрегации.'),
verdict_partial: verdictList('Адреса с вердиктом partial',
'Вердикт partial: часть проверок адреса провалена или результатов не хватило.', true),
verdict_fail: verdictList('Адреса с вердиктом fail',
'Вердикт fail: все проверки адреса провалены.'),
egress_https_any: {
title: 'Egress https: адреса с проваленными проверками',
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.',
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.' }
},
ingress_ssh_any: {
title: 'Ingress ssh: адреса с проваленными проверками',
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.',
hints: { 2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.' }
},
egress_https_all: {
title: 'Egress https: адреса, провалившие все проверки',
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.',
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше полного набора значит, что часть результатов не пришла.' }
},
ingress_ssh_all: {
title: 'Ingress ssh: адреса, провалившие все проверки',
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.',
hints: { 2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.' }
}
};
function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
}
2026-10-04 10:26:37 +03:00
function loadList(kind, cls, meta) { return A.load(listURL(M.list_url, kind, cls), meta.title); }
function runLabel() {
var o = document.getElementById('an-run');
return o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id;
}
function openIndicator(kind) {
var meta = LISTS[kind];
loadList(kind, '', meta).then(function (l) {
if (!l) return;
2026-10-04 10:26:37 +03:00
A.fill({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), csvURL: listURL(M.csv_url, kind, '') });
});
}
function openError(cls) {
loadList('error', cls, { title: 'Ingress: ' + cls }).then(function (l) {
if (!l) return;
var rows = l.rows, labels = R.validators.map(function (v) { return vshort(v.validator); });
rows.forEach(function (x) { if (labels.indexOf(x[3]) < 0) labels.push(x[3]); });
var byVal = labels.map(function (lab) { return rows.filter(function (x) { return x[3] === lab; }).length; });
var max = Math.max.apply(null, byVal.concat([1]));
var late = rows.filter(function (x) { return x[5].indexOf('после') >= 0; }).length;
var pass = rows.filter(function (x) { return x[4] === 'pass'; }).length;
var grid = 'grid-template-columns:repeat(' + labels.length + ',minmax(0,1fr))';
var dist = '<h2>Распределение по валидаторам</h2>' +
'<div class="an-cols20" style="' + grid + '" role="img" aria-label="Число проваленных проверок по валидаторам">' + byVal.map(function (v, i) {
return '<div style="height:' + Math.max(1, v / max * 100) + '%" ' + tipAttr('валидатор ' + labels[i] + ': ' + v + ' проверок') + '></div>';
}).join('') + '</div>' +
'<div class="an-lab20" style="' + grid + '">' + labels.map(function (lab) { return '<span>' + esc(lab.replace(/^v/, '')) + '</span>'; }).join('') + '</div>' +
'<div class="an-chips"><span class="an-tag an-t-n">всего ' + fmt(rows.length) + '</span><span class="an-tag an-t-ok">вердикт pass: ' + fmt(pass) + '</span><span class="an-tag an-t-warn">вердикт partial: ' + fmt(rows.length - pass) + '</span><span class="an-tag an-t-bad">после вердикта: ' + fmt(late) + '</span><span class="an-tag an-t-n">в вердикте: ' + fmt(rows.length - late) + '</span></div>';
2026-10-04 10:26:37 +03:00
A.fill({
title: 'Ingress: ' + cls,
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
2026-10-04 10:26:37 +03:00
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, csvURL: listURL(M.csv_url, 'error', cls),
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal }
});
});
}
/* ---- wiring ---- */
function renderAll() {
renderSubnets();
renderTypes();
renderTargets();
}
$('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' +
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '');
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll();
$('an-kpis').addEventListener('click', function (e) { var b = e.target.closest('[data-list]'); if (b) openIndicator(b.dataset.list); });
$('an-errs').addEventListener('click', function (e) { var b = e.target.closest('[data-cls]'); if (b) openError(b.dataset.cls); });
document.querySelectorAll('[data-sort]').forEach(function (b) {
b.addEventListener('click', function () {
state.sort = b.dataset.sort;
document.querySelectorAll('[data-sort]').forEach(function (x) { x.setAttribute('aria-pressed', x === b); });
renderSubnets();
});
});
$('an-types').addEventListener('click', function (e) {
var b = e.target.closest('[data-type]');
if (!b) return;
state.type = b.dataset.type;
renderTypes(); renderTargets();
});
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); });
})();