Runs (migration 0011): a run groups the cycles of one launch. It opens when an address enters an idle queue, takes everything submitted or re-checked while it is open and is finalized when all its addresses are done; a re-check after that opens a new run, so results of different runs never mix. check_runs, run_results (one result per address and run, with the verdict and the expected and stored check counts), subnets, run_id on ip_queue and checks. Existing data is split into runs at pauses of more than an hour; ingress checks get the validator that held the address (also at write time from now on). Analytics (internal/analytics): figures computed from the stored checks of the latest cycle of each address in the run, as facts next to the verdict: summary, reasons of partial, data quality, subnets, targets and the subnet x target matrix by check type, ingress by site, error classes, validators, and the address lists behind the indicators and error classes. API: analytics runs, report, lists (JSON or CSV), subnet list; run and subnet filters for the registry. Dashboard: /analytics matching the approved mockup (run selector, indicators with address lists and CSV, error-class dialogs, drill-down to the registry), subnet list on /settings. Sidebar: the control-api link state, theme toggle and logout moved to the top, the three dots next to the logo removed, sections grouped. Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
348 lines
25 KiB
JavaScript
348 lines
25 KiB
JavaScript
/* 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;
|
||
var tiles = [
|
||
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
|
||
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>', ''],
|
||
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>', ''],
|
||
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
|
||
S.fail ? '<span class="an-tag an-t-bad">провал</span>' : '<span class="an-tag an-t-n">нет</span>', ''],
|
||
['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 + '&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 + '&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 LISTS = {
|
||
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 = '<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 = '';
|
||
}
|
||
|
||
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 = '<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>';
|
||
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; });
|
||
})();
|