Add the Analytics section: check runs, analytics API and page

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>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-03 18:36:03 +03:00
1 parent 864208238f
commit b7669c9e41
44 files changed
+4123 -62

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
/* Analytics page. Everything is prefixed an-; the dashboard's own tokens
(--surface, --border, --accent, ...) are used as they are. */
:root {
--an-radius: 4px;
--an-shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
--an-bar: #3987e5; --an-bar-track: #E4E9F0;
/* heat ramp: one blue hue, validated sequential steps (light = low) */
--an-h1:#cde2fb; --an-h2:#9ec5f4; --an-h3:#6da7ec; --an-h4:#3987e5; --an-h5:#256abf; --an-h6:#184f95; --an-h7:#0d366b;
--an-hi1:#12161F; --an-hi2:#12161F; --an-hi3:#12161F; --an-hi4:#FFFFFF; --an-hi5:#FFFFFF; --an-hi6:#FFFFFF; --an-hi7:#FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
}
}
:root[data-theme="dark"] {
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
}
.an { display: grid; gap: 16px; align-content: start; min-width: 0; font-family: var(--font-ui); font-size: 14px; line-height: 1.5; }
.an * { box-sizing: border-box; }
.an-title { font: 700 18px var(--font-mono); margin: 0; }
.an h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
.an-sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
.an-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); box-shadow: var(--an-shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
.an-panel > * { min-width: 0; }
.an-head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
.an-runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.an-runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
.an select, .an .an-btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 7px 10px; }
.an select { min-width: 0; max-width: 100%; flex: 1 1 160px; }
.an-btn { cursor: pointer; } .an-btn:hover { background: var(--surface-alt); }
.an-tabs { display: inline-flex; gap: 0; }
.an-tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
.an-tabs button + button { border-left: 0; }
.an-tabs button:first-child { border-radius: var(--an-radius) 0 0 var(--an-radius); } .an-tabs button:last-child { border-radius: 0 var(--an-radius) var(--an-radius) 0; }
.an-tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
.an-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; }
.an-kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--an-shadow); }
.an-kpi .an-k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
.an-kpi .an-v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.an-kpi .an-d { font-size: 12px; color: var(--text-muted); }
.an-tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
.an-tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
.an-t-ok { background: var(--success-soft); color: var(--success); } .an-t-warn { background: var(--warning-soft); color: var(--warning); } .an-t-bad { background: var(--danger-soft); color: var(--danger); } .an-t-n { background: var(--neutral-soft); color: var(--neutral); }
.an-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
.an-rows { display: grid; gap: 7px; }
.an-bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
.an-bar-row .an-n { overflow-wrap: anywhere; }
.an-track { height: 12px; background: var(--an-bar-track); border-radius: 2px; position: relative; }
.an-fill { height: 100%; background: var(--an-bar); border-radius: 0 4px 4px 0; }
.an-num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.an-scroll { overflow-x: auto; }
.an table { border-collapse: collapse; width: 100%; font-size: 13px; }
.an th .an-hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
.an th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an th.an-r, .an td.an-r { text-align: right; }
.an td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
.an td.an-a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
.an td.an-a a { color: var(--accent-strong); text-decoration: none; } td.an-a a:hover { text-decoration: underline; }
.an-pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
.an-pb .an-track { height: 8px; }
.an-heat td.an-c { padding: 2px; }
.an-cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
.an-h1{background:var(--an-h1);color:var(--an-hi1)} .an-h2{background:var(--an-h2);color:var(--an-hi2)} .an-h3{background:var(--an-h3);color:var(--an-hi3)} .an-h4{background:var(--an-h4);color:var(--an-hi4)} .an-h5{background:var(--an-h5);color:var(--an-hi5)} .an-h6{background:var(--an-h6);color:var(--an-hi6)} .an-h7{background:var(--an-h7);color:var(--an-hi7)}
.an-legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
.an-legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
.an-vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
.an-vals .an-col { flex: 1 1 0; min-width: 0; background: var(--an-bar); border-radius: 3px 3px 0 0; }
.an-vals .an-avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
.an-vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
.an-dq { display: grid; gap: 8px; }
.an-dq .an-row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
.an-dq .an-row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
.an-note { font-size: 12px; color: var(--text-muted); }
button.an-kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
button.an-kpi:hover { border-color: var(--accent); }
button.an-kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.an-kpi .an-go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
.an dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
.an dialog[open] { display: flex; flex-direction: column; }
.an dialog::backdrop { background: rgba(10,14,20,.5); }
.an-dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
.an-dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
.an-dlg .an-note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
.an-dlg .an-dist { flex: 0 0 auto; }
.an-dlg .an-list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--an-radius); }
.an-dlg table { font-size: 12.5px; } .an-dlg th { position: sticky; top: 0; background: var(--surface); }
.an-dlg .an-foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
.an-dlg .an-foot .an-acts { display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 520px) { .an-dlg { padding: 12px; gap: 10px; } .an-dlg .an-foot .an-acts { width: 100%; } .an-dlg .an-foot .an-acts .an-btn { flex: 1 1 auto; } }
@media (max-height: 540px) { .an-dlg .an-dist { display: none; } }
@media (max-height: 640px) { .an-dist .an-cols20 { height: 44px; } .an-dlg .an-note { max-height: 3em; } .an-dlg .an-dist .an-chips { display: none; } }
.an-btn.an-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.an-btn.an-primary:hover { background: var(--accent-strong); }
button.an-bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--an-radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
button.an-bar-row:hover { background: var(--surface-alt); }
button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.an-dist { display: grid; gap: 8px; }
.an-dist .an-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.an-dist .an-cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
.an-dist .an-cols20 div { background: var(--an-bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.an-dist .an-lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
#an-tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
@media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
/* dashboard.css turns every table into stacked cards on a phone; the tables on
this page are data tables and scroll sideways instead. */
@media (max-width: 640px) {
.an-kpi .an-v { font-size: 22px; }
.an thead { display: table-header-group; }
.an table { display: table; width: 100%; }
.an tbody { display: table-row-group; width: auto; }
.an tr, .an tbody tr { display: table-row; width: auto; padding: 0; border-bottom: 0; }
.an td { display: table-cell; width: auto; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
}
+347
View File
@@ -0,0 +1,347 @@
/* 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 + '&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 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; });
})();
+15 -14
View File
@@ -180,11 +180,9 @@ main > h2.section-title:first-child { margin-top: 0; }
padding: 18px 12px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 14px;
}
.brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); }
.brand-chrome { display: flex; gap: 4px; }
.brand-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
.brand-mark {
width: 26px; height: 26px;
border-radius: var(--radius-xs);
@@ -218,8 +216,10 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
.nav-link.active svg { opacity: 1; }
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.sidebar-foot {
margin-top: auto;
/* Session block at the top of the sidebar: link state to control-api, theme
toggle and, with login enabled, who is signed in and the logout button. */
.sidebar-session {
display: grid; gap: 8px;
padding: 9px 10px;
border-radius: var(--radius-sm);
background: var(--surface-alt);
@@ -227,9 +227,16 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
font-family: var(--font-display);
font-size: 11px;
color: var(--text-muted);
display: flex; align-items: center; justify-content: space-between; gap: 7px;
}
.sidebar-foot-status { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; margin: 0; }
.session-api { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-user { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-group-title {
font-family: var(--font-display); font-weight: 700; font-size: 10px;
letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint);
padding: 12px 9px 4px;
}
nav.nav-groups > .nav-group-title:first-child { padding-top: 0; }
.pulse-dot {
width: 6px; height: 6px; border-radius: 1px;
background: var(--success);
@@ -238,6 +245,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
animation: pulse 2.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
.pulse-dot.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); animation: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.theme-toggle {
@@ -568,13 +576,6 @@ textarea.autosize { overflow-y: hidden; resize: none; }
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
.login-card .field { flex: 0 0 auto; }
.sidebar-user {
margin-top: auto; margin-bottom: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
}
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-user + .sidebar-foot { margin-top: 0; }
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
progress {