/* 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 error classes in a dialog. 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; function $(id) { return document.getElementById(id); } function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); } 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 esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; } 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 = ' список →'; var tiles = [ ['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''], ['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', 'успех' + GO, 'verdict_pass'], ['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', 'частично' + GO, 'verdict_partial'], ['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной', S.fail ? 'провал' + GO : 'нет', 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) + '% адресов, хотя бы одна цель недоступна', 'прикладной список →', 'egress_https_any'], ['Ingress ssh: есть провалы', fmt(S.ingress_ssh_any_failed), pct1(S.ingress_ssh_any_failed, S.addresses) + '% адресов, хотя бы с одной площадки', 'прикладной список →', '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), 'прикладной список →', 'egress_https_all'], ['Ingress ssh: все провалены', fmt(S.ingress_ssh_all_failed), pct1(S.ingress_ssh_all_failed, S.addresses) + '% адресов, ssh провален со всех площадок', 'прикладной список →', 'ingress_ssh_all'], ['Длительность', esc(M.duration), fmt(S.addresses_per_minute) + ' адр./мин', '', ''], ['Поздние результаты', fmt(late), 'у ' + fmt(Q.late_failed_addresses_at_pass) + ' адресов, после вердикта', late ? 'внимание' : 'в норме', ''] ]; $('an-kpis').innerHTML = tiles.map(function (t) { var inner = '
' + t[0] + '
' + t[1] + '
' + t[2] + ' ' + t[3] + '
'; return t[4] ? '' : '
' + inner + '
'; }).join(''); } function bar(v, max) { return '
'; } 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 '
' + esc(x.name) + '' + bar(x.count, max) + '' + fmt(x.count) + '
'; }).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 '
' + r[0] + '' + r[1] + '
'; }).join(''); } function pbar(a, n) { var p = pct(a, n); return '
' + p + '%
'; } 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 = 'ПодсетьАдресовpassEgress OKIngress OK' + shown.map(function (s) { var name = s.label ? s.cidr + ' · ' + s.label : s.cidr; var link = s.cidr === 'прочие' ? esc(name) : '' + esc(name) + ''; return '' + link + '' + fmt(s.addresses) + '' + '' + pbar(s.pass, s.addresses) + '' + '' + pbar(s.egress_ok, s.addresses) + '' + '' + pbar(s.ingress_ok, s.addresses) + ''; }).join('') + ''; } 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 ''; }).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 '
' + esc(n) + '' + bar(vals[i], max) + '' + fmt(vals[i]) + ' · ' + pct(vals[i], S.addresses) + '%
'; }).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 ' ' + l + '%'; }).join(''); $('an-matrix').innerHTML = 'Подсетьpartial' + T.targets.map(function (x) { return '' + esc(x.replace('.com', '').replace('.org', '')) + ''; }).join('') + '' + rows.map(function (m) { var link = m.cidr === 'прочие' ? esc(m.cidr) : '' + esc(m.cidr) + ''; return '' + link + '' + fmt(m.partial) + '' + m.percent.map(function (v, j) { return '' + v + '%'; }).join('') + ''; }).join('') + ''; $('an-matrixnote').textContent = 'Доля адресов partial подсети, провалившие ' + t + ' к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.'; } function renderSites() { var T = R.sites; $('an-sites').innerHTML = 'Площадка' + T.types.map(function (t) { return '' + esc(t) + ''; }).join('') + '' + T.rows.map(function (row) { return '' + esc(row.site) + '' + row.stats.map(function (st, i) { return '' + pct1(st.total - st.ok, st.total) + '% провал'; }).join('') + ''; }).join('') + ''; } 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 ''; }).join('') : '

Проваленных ingress-проверок нет.

'; } 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 '
'; }).join('') + '
'; $('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», нет маршрута.' } } }; var cur = null; // the list shown in the dialog function listURL(base, kind, cls) { return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : ''); } 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 openDialog() { var d = $('an-dlg'); if (d.open) return; if (d.showModal) d.showModal(); else d.setAttribute('open', ''); } function fillDialog(m) { cur = m; $('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length); $('an-dlg-note').textContent = 'Запуск: ' + m.runLabel + '. ' + m.note; $('an-dlg-dist').hidden = !m.dist; $('an-dlg-dist').innerHTML = m.dist || ''; var hint = m.hints || {}; $('an-dlg-tbl').innerHTML = '' + m.cols.map(function (c, i) { return hint[i] ? '' + esc(c) + '' : '' + esc(c) + ''; }).join('') + '' + m.rows.map(function (x) { return '' + x.map(function (v, i) { var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : ''; return '' + esc(v) + ''; }).join('') + ''; }).join('') + ''; $('an-dlg-msg').textContent = ''; } function loadList(kind, cls, meta) { $('an-dlg-title').textContent = meta.title; $('an-dlg-note').textContent = 'Загрузка…'; $('an-dlg-dist').hidden = true; $('an-dlg-tbl').innerHTML = ''; $('an-dlg-msg').textContent = ''; cur = null; openDialog(); return fetch(listURL(M.list_url, kind, cls), { 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 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; fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' }); }); } 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 = '

Распределение по валидаторам

' + '' + '
' + labels.map(function (lab) { return '' + esc(lab.replace(/^v/, '')) + ''; }).join('') + '
' + '
всего ' + fmt(rows.length) + 'вердикт pass: ' + fmt(pass) + 'вердикт partial: ' + fmt(rows.length - pass) + 'после вердикта: ' + fmt(late) + 'в вердикте: ' + fmt(rows.length - late) + '
'; fillDialog({ title: 'Ingress: ' + cls, note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.', cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls, hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT }, cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal } }); }); } 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 = listURL(M.csv_url, cur.kind, cur.cls); } /* ---- 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(); }); $('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; }); })();