Registry and Analytics: run, subnet, direction and protocol filters, successes-by-target chart

Registry (/registry):
- filters by run (slice by the address's cycle in that run), subnet
  (drop-down of configured subnets), direction (egress/ingress) and
  protocol (icmp, tcp, ssh, https, tls); status in scope is computed over
  the narrowed checks
- chart "successful checks per target (egress) / site (ingress)" when both
  direction and protocol are chosen; a row opens the list of addresses
  (dialog, CSV)
- API: direction/protocol parameters and run in GET /admin/registry,
  GET /admin/registry/breakdown and /breakdown/list
- subnet filter passes ids as one JSON parameter (SQLite variable limit)

Analytics (/analytics):
- subnet filter recomputes the whole page over the addresses of the run
  inside the subnet; only their checks are read; cache per run and subnet
- direction and protocol focus the page; with both set the registry chart
  is shown
- subnet parameter in GET /admin/analytics/runs/{id} and lists (JSON, CSV)

Docs: plans and summaries in docs/changes, README, API, USAGE.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-06 14:23:48 +03:00
1 parent 068c10ea1c
commit ded196ec8d
40 files changed
+2545 -188

No files matched your search

+10
View File
@@ -36,6 +36,16 @@
.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 [hidden] { display: none !important; }
.an-body { display: grid; gap: 16px; min-width: 0; align-content: start; }
#an-filter { display: grid; gap: 12px; }
/* filter fields: label above the select, wrapping as whole pairs; in a column the fields keep their own height */
.an-fields { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; }
.an-field { display: flex; flex-direction: column; gap: 5px; flex: 1 1 160px; min-width: 0; }
.an-field label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
.an-field select { flex: 0 0 auto; }
.an-reset { text-decoration: none; }
.an .bd-wrap { margin-bottom: 0; } /* the chart of the registry brings its own space; here the grid gap is enough */
.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; }
+25 -8
View File
@@ -1,7 +1,9 @@
/* 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 the shared dialog (analytics-dialog.js). No other run's data
is on the page. */
is on the page. The report is already narrowed to the chosen subnet; the
direction and protocol only focus the page (blocks of the other level are
hidden, the type tab and the sites column follow the protocol). */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
@@ -14,6 +16,7 @@
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] || '') };
if (R.targets.types.indexOf(M.protocol) >= 0) state.type = M.protocol;
/* ---- indicators ---- */
function renderKpis() {
@@ -124,11 +127,12 @@
}
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>' +
var T = R.sites, only = T.types.indexOf(M.protocol); // with a protocol only its column stays
function shown(x, i) { return only < 0 || i === only; }
$('an-sites').innerHTML = '<thead><tr><th>Площадка</th>' + T.types.filter(shown).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>';
return shown(st, i) ? '<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>';
}
@@ -206,14 +210,14 @@
};
function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '') + (M.subnet ? '&subnet=' + encodeURIComponent(M.subnet) : '');
}
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;
return (o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id) + (M.subnet ? ' · подсеть ' + M.subnet : '');
}
function openIndicator(kind) {
@@ -250,6 +254,12 @@
});
}
/* ---- focus: direction and protocol ---- */
function applyFocus() {
$('an-sec-in').hidden = M.direction === 'egress';
$('an-sec-eg').hidden = $('an-sec-val').hidden = M.direction === 'ingress';
}
/* ---- wiring ---- */
function renderAll() {
renderSubnets();
@@ -258,8 +268,15 @@
}
$('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' +
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '');
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll();
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '') +
(R.scope ? ' Подсеть ' + R.scope.subnet + ': ' + fmt(S.addresses) + ' адр. из ' + fmt(R.scope.run_addresses) + ' в запуске.' : '');
if (R.scope && !S.addresses) {
$('an-empty').textContent = 'В запуске нет адресов этой подсети.';
$('an-empty').hidden = false;
$('an-body').hidden = true;
return;
}
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll(); applyFocus();
$('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); });
+8
View File
@@ -507,8 +507,16 @@ code.inline { font-family: var(--font-mono); background: var(--surface-alt); bor
.main { padding: 16px 14px 50px; }
}
.bd-wrap { margin-bottom: 16px; }
/* Chart of the registry: the figures column has one width in every row, so the bars line up. */
#registry-breakdown .an-bar-row { grid-template-columns: minmax(110px, 30%) minmax(0, 1fr) 15em; }
@media (max-width: 640px) {
.field-row { flex-direction: column; align-items: stretch; }
/* the registry fields carry an inline flex basis (for rows); in a column it would become a height */
#registry-filter .field { flex: 0 0 auto !important; }
#registry-breakdown .an-bar-row { grid-template-columns: minmax(0, 1fr) auto; }
#registry-breakdown .an-bar-row .an-track { grid-column: 1 / -1; grid-row: 2; }
thead { display: none; }
table, tbody, tr, td { display: block; width: 100%; }
tbody tr { border-bottom: 1px solid var(--border-soft); padding: 9px 16px; }
+47
View File
@@ -0,0 +1,47 @@
/* The chart of the registry ("registry_breakdown" in registry.html): a click on
a row opens the checks behind it in the analytics dialog (analytics-dialog.js).
The listener sits on the document, so it keeps working after htmx replaces the
table; the filter of the list is the one the chart was built with (data-qs). */
(function () {
'use strict';
if (window.registryBreakdownBound) return;
window.registryBreakdownBound = true;
var HINTS = {
'Результат': 'Итог именно этой проверки. Список начинается с проваленных.',
'Тип проверки': 'https, icmp, ssh, tcp-22, tls-443 и т. д.',
'Валидатор': 'Валидатор, с которого шла egress-проверка.',
'Задержка, мс': 'Сколько заняла проверка.',
'Детали': 'Ошибка проверки, если она провалена.'
};
function open(btn) {
var A = window.AnalyticsDialog, box = btn.closest('#registry-breakdown');
if (!A || !box) return;
var tail = (box.dataset.qs ? box.dataset.qs + '&' : '') + 'key=' + encodeURIComponent(btn.dataset.bdKey);
var title = box.dataset.scope + ' · ' + btn.dataset.bdLabel;
A.load('/registry/breakdown/list?' + tail, title).then(function (l) {
if (!l) return;
var ok = l.rows.filter(function (r) { return r[1] === 'успешно'; }).length;
var hints = {};
l.columns.forEach(function (c, i) { if (HINTS[c]) hints[i] = HINTS[c]; });
A.fill({
title: title,
scope: 'Срез',
runLabel: box.dataset.slice,
note: 'Проверки этого типа в цикле адреса среза, у адресов под текущим фильтром. Провалы сверху.',
cols: l.columns, rows: l.rows, hints: hints,
dist: '<div class="an-chips"><span class="an-tag an-t-ok">успешно ' + A.fmt(ok) + '</span><span class="an-tag an-t-bad">провал ' + A.fmt(l.rows.length - ok) + '</span></div>',
csvURL: '/registry/breakdown/csv?' + tail
});
// the error text of a failed check is long: give its column room instead of a narrow wrapped strip
var d = l.columns.indexOf('Детали');
if (d >= 0) document.querySelectorAll('#an-dlg-tbl tr').forEach(function (tr) { if (tr.children[d]) tr.children[d].style.minWidth = '280px'; });
});
}
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-bd-key]');
if (b) open(b);
});
})();