diff --git a/README.md b/README.md index ff24229..10cc773 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,8 @@ docs/ документация и планы доработок | Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` | | Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` | | Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) | -| Реестр | `GET /admin/registry` (`limit/offset/q/last_result/run/subnet` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` | -| Аналитика | `GET /admin/analytics/runs`, `GET /admin/analytics/runs/{id}`, `GET /admin/analytics/runs/{id}/lists/{kind}` (JSON или `?format=csv`), `GET`/`PUT /admin/config/subnets` | +| Реестр | `GET /admin/registry` (`limit/offset/q/last_result/run/subnet/direction/protocol` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам; `run` — срез по запуску), `GET /admin/registry/breakdown`, `GET /admin/registry/breakdown/list` (чарт «успешные проверки по целям/площадкам» и список адресов за строкой; те же фильтры, `format=csv`), `GET /admin/registry/{ip}` | +| Аналитика | `GET /admin/analytics/runs`, `GET /admin/analytics/runs/{id}` (`?subnet=` — отчёт по подсети), `GET /admin/analytics/runs/{id}/lists/{kind}` (JSON или `?format=csv`), `GET`/`PUT /admin/config/subnets` | | Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` | | Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` | | Служебное | `GET /admin/validators`, `GET /healthz` | @@ -166,8 +166,8 @@ docs/ документация и планы доработок |---|---| | `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов | | `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса | -| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) | -| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска; списки адресов с выгрузкой в CSV | +| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтры (статус, запуск, подсеть, направление, протокол) и страница сохраняются в адресной строке; при выбранных направлении и протоколе — чарт успешных проверок по целям или площадкам Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) | +| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска и фильтры (подсеть, направление, протокол), чарт успешных проверок по целям/площадкам; списки адресов с выгрузкой в CSV | | `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок | | `/settings` | Панель «Автоматический цикл», пауза перед self-check, потолок провалов self-check на адрес, глубина истории, TCP-порты и ICMP для inbound-проверок | - Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается. @@ -208,6 +208,9 @@ scripts/run-local-e2e.sh # сквозной прог | Дата | Веха | Документ | |---|---|---| +| 2026-10-06 | Аналитика: фильтры подсеть (пересчёт всей страницы по адресам подсети), направление и протокол (фокус страницы) и чарт «успешные проверки по целям / площадкам» из реестра; параметр `subnet` в `GET /admin/analytics/runs/{id}` и списках | [план](docs/changes/2026-10-06_13-55_analytics-filters-plan.md) · [итог](docs/changes/2026-10-06_13-55_analytics-filters-summary.md) · [USAGE](docs/USAGE.md#аналитика-запусков) · [API](docs/API.md#аналитика-запусков) | +| 2026-10-06 | Реестр: подсеть — выпадающий список; чарт «успешные проверки по целям (Egress) / площадкам (Ingress)» при выбранных направлении и протоколе, строка открывает список адресов (диалог, CSV); API `registry/breakdown` | [план](docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md) · [итог](docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistrybreakdown) | +| 2026-10-06 | Реестр: фильтры по запуску (срез по циклу адреса в запуске), подсети, направлению (Egress/Ingress) и протоколу (icmp, tcp, ssh, https, tls); параметры `direction`, `protocol` и поле `run` в `GET /admin/registry` | [план](docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md) · [итог](docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) | | 2026-10-04 | Аналитика: сравнение двух запусков (`/analytics/compare`): новые, выбывшие и изменившиеся адреса, динамика по семи индикаторам, матрица переходов вердикта; API `analytics/compare` | [план](docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md) · [итог](docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md) | | 2026-10-04 | Аналитика: карточки `pass`, `partial`, `fail` открывают список адресов с этим вердиктом и выгрузку в CSV (`lists/verdict_*`) | [план](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-plan.md) · [итог](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-summary.md) | | 2026-10-04 | Повтор после сбоя self-check — на другом валидаторе: валидатор, проваливший self-check, этому адресу больше не выдаётся; потолок провалов `self_check_max_attempts` (по умолчанию 5, миграция `0012`); поле `self_check_failed_on` | [план](docs/changes/2026-10-04_08-01_self-check-exclude-validator-plan.md) · [итог](docs/changes/2026-10-04_08-01_self-check-exclude-validator-summary.md) | diff --git a/docs/API.md b/docs/API.md index 2d8186b..e9a0658 100644 --- a/docs/API.md +++ b/docs/API.md @@ -671,8 +671,8 @@ curl -s -X POST http://:8080/api/v1/admin/auto-cycle/stop ### `GET /api/v1/admin/registry` Список адресов реестра с краткой сводкой по каждому. Без параметров — все адреса одним массивом; **с `limit`** (`1`…`1000`) — -постраничный конверт `{"items": [...], "total": N, "limit": L, "offset": O}`, параметры `offset`, `q` (подстрока адреса) и -`last_result` (`pass`/`partial`/`fail`/`cancelled`). Страница и фильтры применяются в SQL до расчёта сводки, поэтому +постраничный конверт `{"items": [...], "total": N, "limit": L, "offset": O, "run": R}`, параметры `offset`, `q` (подстрока адреса) и +`last_result` (`pass`/`partial`/`fail`/`cancelled`); остальные фильтры — ниже. Страница и фильтры применяются в SQL до расчёта сводки, поэтому реестр из тысяч адресов отдаётся за доли секунды. ```json @@ -714,9 +714,77 @@ curl -s -X POST http://:8080/api/v1/admin/auto-cycle/stop результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из `total`». -Фильтры постраничного режима (только вместе с `limit`): `run` — только адреса, -у которых есть результат в этом запуске (см. [«Аналитика запусков»](#аналитика-запусков)); `subnet` — только -адреса внутри подсети (CIDR, например `203.0.113.0/24`). Неверный `run` или `subnet` — `400`. +Фильтры постраничного режима (только вместе с `limit`), комбинируются через И: + +| Параметр | Значения | Смысл | +|----------|----------|-------| +| `run` | id запуска | Только адреса запуска (см. [«Аналитика запусков»](#аналитика-запусков)); открытый (идущий) запуск тоже допустим, данные в нём частичные. Неизвестный запуск — пустой список. | +| `subnet` | CIDR, например `203.0.113.0/24` | Только адреса внутри подсети. | +| `direction` | `egress`, `ingress` | Только проверки этого направления. | +| `protocol` | `icmp`, `tcp`, `ssh`, `https`, `tls` | Только проверки этого семейства (`tcp` = `tcp-22`, `tcp-443` и т. д.). `tls` бывает только на входе, поэтому `direction=egress&protocol=tls` всегда даёт пустой список. | + +Неверные `run`, `subnet`, `direction`, `protocol` и `last_result` — `400`. + +**Запуск как срез данных.** Без `run` поля ответа считаются по последнему +циклу адреса. С `run` — по циклу адреса **в этом запуске**: `last_cycle_id` — +цикл запуска, `last_result` — вердикт запуска (как на странице «Аналитика»), +`egress`/`ingress` — проверки этого цикла. Если проверки запуска уже удалены +(очистка истории, `history_retention_cycles`), адрес остаётся в списке, а +уровни пусты (`total: 0`). Значение `run` возвращается в конверте (`0` — срез +не задан). + +**Направление и протокол** сужают область проверок: в `egress`/`ingress` и +`by_type` попадают только подходящие проверки, а адрес без таких проверок из +списка исключается. `last_result`-фильтр при этом считается по проверкам +области, а не по вердикту: все успешны — `pass`, ни одной — `fail`, иначе +`partial` (вердикт учитывает и недостающие результаты, поэтому может +отличаться); `cancelled` вместе с `direction`/`protocol` всегда даёт пустой +список. Поле `last_result` в самих строках остаётся вердиктом (запуска или +последнего цикла). + +Пример: адреса подсети, у которых в запуске 3 все входящие проверки `https` +неуспешны: + +``` +GET /api/v1/admin/registry?limit=50&run=3&subnet=203.0.113.0/24&direction=ingress&protocol=https&last_result=fail +``` + +### `GET /api/v1/admin/registry/breakdown` + +Успешные проверки по целям (egress) или площадкам (ingress) — данные чарта над таблицей реестра. Параметры те же, что +у `GET /api/v1/admin/registry` (`q`, `last_result`, `run`, `subnet`, `direction`, `protocol`; `limit` и `offset` не нужны), но +`direction` и `protocol` **обязательны** — без любого из них `400`. Срез и набор адресов тоже те же, что у списка: все адреса под фильтром (не +страница), цикл адреса в запуске `run` или его последний цикл, проверки выбранных направления и протокола. + +```json +{ + "group": "site", "direction": "ingress", "protocol": "tcp", "run": 3, "addresses": 6440, + "rows": [ + {"key": "inbound-site-2", "label": "rxspb", "total": 12880, "ok": 12790}, + {"key": "inbound-site-1", "label": "rxmsk", "total": 12880, "ok": 12611} + ] +} +``` + +`group` — `target` (egress, группировка по `checks.target`) или `site` (ingress, по `checks.source`); `addresses` совпадает с `total` +списка при тех же фильтрах. `key` — исходное значение (его принимает `…/list?key=`), `label` — подпись: у цели адрес без схемы и +завершающего `/` (`repo.almalinux.org/almalinux`), у площадки её имя из настройки (`site-N`, если площадка уже удалена). Считаются +**проверки**, а не адреса: `total` — записанные проверки, `ok` — успешные; у `tcp` и `tls` на ingress у адреса бывает по проверке на +каждую площадку и порт. Строки идут от большего `ok` к меньшему, при равенстве — по `label`. Нет проверок (например, `egress` + `tls`) — +`rows: []`. + +### `GET /api/v1/admin/registry/breakdown/list` + +Проверки одной строки чарта: те же параметры плюс `key` (обязателен, значение `key` из строки чарта). Ответ — таблица +`{"columns": [...], "rows": [[...]]}`; сначала провалы, затем в порядке реестра. Столбцы: `Адрес`, `Результат` (`успешно`/`провал`), +`Тип проверки` (`https`, `tcp-22`…), `Цель` (egress) или `Площадка` (ingress), `Валидатор` (только egress), `Задержка, мс`, `Детали` +(ошибка), `Проверено (UTC)`. С `format=csv` — тот же список файлом (`registry_<направление>_<протокол>_<ключ>.csv`, UTF-8 с BOM). +`404` — у ключа нет проверок в этом срезе; `400` — нет `key`, `direction` или `protocol`. + +``` +GET /api/v1/admin/registry/breakdown?run=3&direction=egress&protocol=https +GET /api/v1/admin/registry/breakdown/list?run=3&direction=egress&protocol=https&key=https://repo.almalinux.org/almalinux/&format=csv +``` ### `GET /api/v1/admin/registry/{ip}` @@ -1047,10 +1115,18 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool Все показатели страницы по одному **завершённому** запуску; открытый запуск — `409`, неизвестный — `404`. Считаются проверки последнего цикла каждого адреса в запуске, в том числе пришедшие позже вердикта (как факты). -Результат кэшируется, пока данные запуска и список подсетей не менялись. +Результат кэшируется, пока данные запуска и список подсетей не менялись; кэш ведётся по паре «запуск + подсеть» и хранит не больше 16 записей (вытесняется давно не запрашивавшаяся). + +Необязательный параметр `subnet` (CIDR, например `203.0.113.0/24`) пересчитывает все блоки по адресам запуска внутри подсети +(вложенные подсети тоже; биты хоста маскируются). Неверный CIDR — `400`. В ответе тогда есть блок `scope`. + +``` +GET /api/v1/admin/analytics/runs/7?subnet=203.0.113.0/24 +``` | Блок | Содержимое | |---|---| +| `scope` | только с `subnet`: `subnet` (CIDR в каноничной записи) и `run_addresses` (все адреса запуска без `cancelled`; `summary.addresses` — адреса подсети). `run.rechecked` остаётся по всему запуску | | `run` | `id`, `kind`, `state`, `started_at`, `finalized_at`, `duration_seconds`, `rechecked` (адресов с несколькими циклами в запуске) | | `summary` | `addresses`, `pass`, `partial`, `fail`, `cancelled`; `egress_ok`, `ingress_ok` (адреса, у которых все записанные проверки уровня успешны); `egress_https_any_failed` и `egress_https_all_failed` (хотя бы одна / все https-проверки провалены), `egress_https_all_targets_failed` (все цели полного набора); `ingress_ssh_any_failed`, `ingress_ssh_all_failed`; `addresses_per_minute` | | `reasons` | причины `partial`, каждый адрес один раз: «Только egress», «Ingress и egress», «Egress и неполный набор», «Ingress, egress и неполный набор», «Только неполный набор», «Только ingress»; нулевые не выдаются | @@ -1067,6 +1143,7 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool ### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}` Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`. +С `?subnet=` — только адреса этой подсети, так что строки совпадают с числами отчёта той же подсети; неверный CIDR — `400` (то же для CSV). `kind`: `verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`; без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`, имя вида `ingress_ssh_all_run1.csv`). Для `verdict_*` — адреса запуска с этим вердиктом (без `cancelled`, по числовому порядку; число строк равно `summary.pass`/`partial`/`fail`): адрес, подсеть, валидатор (по https-проверкам, «—», если их нет), `Egress` и `Ingress` («успешно из всех», «—» без проверок), «Проверок в цикле» (записано из ожидаемых) и у `partial` ещё «Причина» (как в блоке `reasons`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка, diff --git a/docs/USAGE.md b/docs/USAGE.md index f7a040a..ee17556 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -412,6 +412,51 @@ curl -s http://:8080/api/v1/admin/registry/203.0.113.10 | python3 - видно по счётчикам (подсказка при наведении). Те же данные — в `GET /api/v1/admin/registry` ([API.md](API.md#get-apiv1adminregistry)). +**Фильтры реестра.** Помимо поиска по IP, статуса и числа строк на странице, +доступны (все сохраняются в адресной строке и при листании): + +- **Запуск** — задача сканирования из накопленной истории (список тот же, что + на «Аналитике»; идущие запуски помечены «идёт»). Выбранный запуск оставляет + только его адреса, а результат и статус берутся по циклу адреса *в этом + запуске* (статус — вердикт запуска). «Последний цикл» — поведение по умолчанию. +- **Подсеть** — выпадающий список настроенных подсетей (с метками); показываются + адреса реестра, входящие в подсеть. Подсеть из ссылки аналитики, которой нет в + списке, добавляется отдельным пунктом. Если подсети не настроены, список + недоступен — рядом ссылка на `/settings`, где их можно добавить. Через API + (`subnet=`) по-прежнему принимается любой CIDR. +- **Направление** — `Egress` или `Ingress`. +- **Протокол** — `icmp`, `tcp` (все порты), `ssh`, `https`, `tls`. `tls` — это + TLS-хендшейк пробера на 443, он бывает только на входе: «Egress + tls» всегда + пуст. + +Направление и протокол оставляют только такие проверки цикла: адрес должен +иметь хотя бы одну, а счётчики «N из M» в строке считаются только по ним. +Вместе со статусом он считается по этим проверкам (все успешны — `pass`, ни +одной — `fail`, иначе `partial`), поэтому может отличаться от общего +вердикта. `cancelled` вместе с направлением или протоколом ничего не находит. +Если история циклов обрезана (`history_retention_cycles`), у старого запуска +проверок может не остаться — уровни покажут «—». + +**Чарт «Успешные проверки по целям / площадкам».** Когда выбраны и направление, и +протокол, над таблицей появляется чарт по проверкам выбранного типа в цикле среза +(цикл адреса в выбранном запуске либо последний): + +- **Egress** — одна строка на цель (`github.com`, `hub.docker.com`…); +- **Ingress** — одна строка на площадку пробера. + +В строке — «успешно из всего» и доля, например `4 296 из 6 490 · 66,2%`; строки идут +от большего числа успешных проверок к меньшему. Чарт считается по всем адресам под +текущим фильтром (число — в строке «Найдено адресов»), а не по одной странице. +Считаются именно проверки: у `tcp` и `tls` на ingress у адреса может быть по проверке +на каждый порт (22, 443) для каждой площадки. Сочетание без проверок (например, +`Egress` + `tls`) показывает «Для этого сочетания проверок нет». + +Строка чарта открывает список адресов с этой целью или площадкой: провалы сверху, +столбцы «Результат», «Тип проверки», «Цель»/«Площадка», «Валидатор» (egress), +«Задержка», «Детали» (ошибка) и «Проверено»; есть «Копировать» и «Скачать CSV». +API: [`GET /admin/registry/breakdown`](API.md#get-apiv1adminregistrybreakdown) и +`…/breakdown/list`. + **Глубина хранения.** Чтобы история не росла бесконечно на адресах, которые перепроверяют очень часто, можно ограничить, сколько последних циклов проверки хранить на каждый адрес — `history_retention_cycles` на @@ -428,6 +473,21 @@ curl -s http://:8080/api/v1/admin/registry/203.0.113.10 | python3 - `partial`, подсети, провалы по целям, ingress по площадкам, классы ошибок, валидаторы и качество данных. Данные других запусков на странице не участвуют, поэтому результаты разных прогонов не пересекаются. +**Фильтры страницы.** Под выбором запуска — те же фильтры, что в реестре; значения лежат в адресе +(`/analytics?run=&subnet=&direction=&protocol=`), поэтому ссылку можно сохранить; стрелки ◀ ▶ и ссылки в реестр их переносят: + +- **Подсеть** — выпадающий список настроенных подсетей. Вся страница пересчитывается по адресам запуска, входящим в + подсеть (вложенные тоже): плитки, причины `partial`, качество данных, цели, матрица, площадки, ошибки, валидаторы; + списки за плитками и CSV режутся из того же набора. В подписи — «подсеть X: N адр. из M в запуске». Подсеть без + адресов в запуске показывает пояснение. Расчёт новой подсети занимает до секунды, повторный — мгновенно (кэш до 16 + записей). +- **Направление** и **протокол** не пересчитывают вердикты и «Качество данных», а задают фокус: Egress скрывает блоки + ingress, Ingress — блоки egress и валидаторов; протокол выбирает вкладку типа в «Egress по целям» и столбец типа в + «Ingress по площадкам». +- Когда выбраны **оба**, над плитками появляется чарт «Успешные проверки по целям / площадкам» — тот же, что в реестре + (чарт считает проверки цикла запуска, в том числе пришедшие после вердикта, поэтому его числа могут расходиться + с плитками, которые берут вердикты). Строка чарта открывает список адресов с CSV. + **Что такое запуск.** Запуск открывается, когда адрес попадает в пустую (или полностью обработанную) очередь, а скан автоцикла помечает его как `авто`. Пока он открыт, в него входят все добавленные и перепроверяемые адреса. Когда у всех адресов запуска есть итог, запуск завершается и появляется в списке. Перепроверка после этого diff --git a/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md b/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md new file mode 100644 index 0000000..45775ad --- /dev/null +++ b/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md @@ -0,0 +1,74 @@ +# План: фильтры «Запуск», «Подсеть», «Направление», «Протокол» в разделе «Реестр» + +Редакция 3 (2026-10-06): добавлен выбор запуска (задачи сканирования) из накопленной истории; в список протоколов добавлен `tls`. + +## Задача + +В `/registry` добавить четыре фильтра; существующие (поиск по IP, статус, «на странице») сохраняются: + +1. **Запуск** — выбор «цикла сканирования» (задачи) из истории запусков. +2. **Подсеть** — показать адреса реестра, входящие в подсеть. +3. **Направление** — Egress или Ingress. +4. **Протокол** — icmp, tcp, ssh, https, tls. + +## Что уже есть (граф + README + код) + +- **Запуск = `check_runs`** (миграция 0011): одна задача сканирования, ручная или автоцикла, со списком адресов (`run_results`: адрес, `cycle_id`, вердикт) и привязкой проверок (`checks.run_id`, индекс `idx_checks_run(run_id, registry_id, cycle_id)`). История копируется, пока её не очистили по `docs/ADMIN_CLEANUP.md`. Список запусков уже отдаёт `GET /admin/analytics/runs` (клиент `ListAnalyticsRuns`, подпись `runLabel`) — используем его же. Номер цикла `cycle_id` считается по адресу и запуск не определяет, поэтому выбираем именно запуск. +- **Фильтр `run` уже есть**, но неполный: `RegistryFilter.RunID` лишь ограничивает список адресами запуска (`queries_registry.go:187`), а столбец «Последний результат» по-прежнему показывает **последний цикл адреса**, а не результат в выбранном запуске. Сейчас он включается только переходом из аналитики, элемента выбора нет. +- **Подсеть:** фильтр есть в API и дашборде (`subnet`, `subnetIDs`), UI-элемента нет. Список подсетей с метками есть (`GetSubnets`). +- **Направление и протокол** в БД отдельных колонок не имеют: выводятся из `checks.source` и `checks.check_type`, правила — `CheckLevel`, `CheckFamily` (`models.go:84-106`). Миграция не нужна. +- Уровни Egress/Ingress и разбивка по типам в таблице уже показываются (`fillRegistryLevels`). + +## Решения (приняты по умолчанию — подтвердить) + +1. **Запуск задаёт срез данных.** Фильтры направления/протокола/статуса и столбец «Результат» считаются по циклу адреса **в выбранном запуске** (`run_results.cycle_id`). Без запуска — по последнему циклу адреса, как сейчас. Выбранный запуск также ограничивает список его адресами. +2. **Статус в запуске** — это вердикт из `run_results.verdict` (не сегодняшний `ip_queue.overall_result`). Так таблица совпадает со страницей «Аналитика» за этот запуск. Статус `cancelled` доступен только здесь и без области направления/протокола. +3. **Направление и протокол сужают область проверок**, работают вместе со статусом. Пример: запуск 3 + Ingress + https + fail = адреса, у которых в запуске 3 все проверки https на входе неуспешны. Без статуса — адреса, у которых такие проверки есть. Статус в области: все успешны — `pass`, ни одной — `fail`, иначе `partial` (по проверкам области, а не вердикту). +4. **Протоколы** — по семейству: `tcp` = `tcp-22`, `tcp-443` и т. д. В UI пять значений: `icmp`, `tcp`, `ssh`, `https`, `tls`. `tls` — входящая проверка (`tls-443`, TLS-хендшейк пробера на 443); исходящих `tls` нет, поэтому `Egress` + `tls` даёт пустой результат — это ожидаемо (подсказка в UI: «tls проверяется только на входе»). Позволяет найти адреса, где TCP на 443 открыт, а TLS не поднимается. +5. **Выбор запуска** — выпадающий список «Последний цикл (по умолчанию)» + запуски, новые первыми, подпись `runLabel`. Открытые (идущие) запуски доступны: данные частичные, это видно в подписи («идёт»), в отличие от аналитики, где они недоступны. +6. **Подсеть** — выпадающий список настроенных подсетей (с меткой) плюс ручной ввод CIDR (``). Невалидный CIDR игнорируется. +7. **Таблица в области** показывает только затронутые уровни и типы (при Ingress скрыт Egress, при `https` — только чипы https). Заголовок столбца: «Последний результат» или «Результат в запуске N». + +## Изменения + +### 1. БД (`internal/db`) +- `RegistryFilter`: добавить `Level` (`egress|ingress`), `Family` (`icmp|tcp|ssh|https|tls`); `RunID` остаётся. +- `ListRegistryPage` (общая логика выбора цикла адреса `scopeCycle`): + - без запуска: цикл = `MAX(cycle_id)` адреса (как сейчас); + - с запуском: список — `run_results WHERE run_id=?`, цикл = `run_results.cycle_id`, вердикт = `run_results.verdict`; проверки берутся с `checks.run_id=?` и этим циклом (идёт по `idx_checks_run`). + - условие статуса: без области — по `lastResultCond` (без запуска) или `run_results.verdict` (с запуском); с областью — `CASE SUM(success)…` по проверкам области. + - условие области (`EXISTS`/`CASE`): `source='egress'` или `LIKE 'inbound-site-%'`, `check_type = ? OR LIKE ?||'-%'`. +- `fillRegistrySummary` / `fillRegistryLevels`: принимать срез (запуск, уровень, семейство) и заполнять `LastResult`, `LastCycleID`, `Egress`, `Ingress` из него. Без среза — как сейчас, поведение прежнее. +- `subnetIDs`: передавать идентификаторы одним JSON-параметром (`r.id IN (SELECT value FROM json_each(?))`) вместо `?,?,?…`; сейчас при >32 766 адресов в подсети запрос упадёт по лимиту SQLite. Проверить `json_each` в `modernc.org/sqlite` v1.57. +- Неизвестные значения → `ErrValidation`; неизвестный `run` → пустой список. + +### 2. HTTP API (`internal/httpapi`) +- `GET /admin/registry`: новые параметры `direction` (`egress|ingress`) и `protocol` (`icmp|tcp|ssh|https|tls`); неверные → 400 с перечнем. Параметры `run` и `subnet` уже есть. Поля ответа прежние, но при `run` они отражают результат в запуске; в ответ страницы добавить `run` (id среза или 0). +- `docs/API.md` — раздел реестра: параметры, смысл `run`, пример. + +### 3. Дашборд (`internal/dashboard`) +- `client.go`: `registryQuery` + `Direction`, `Protocol`. +- `handlers_registry.go`: читать `run`, `subnet`, `direction`, `protocol` из URL, проверять по белому списку, класть в `params` пагинатора (фильтры сохраняются при листании и в адресной строке). Данные для выбора: `ListAnalyticsRuns` и `GetSubnets`. Ошибка получения списков не ломает страницу: фильтр остаётся текстовым/без подписей, показывается баннер. +- `templates/registry.html`: в форму `#registry-filter` — четыре поля «Запуск», «Подсеть», «Направление», «Протокол» с теми же `hx-get`/`hx-include`/`hx-replace-url`, что у существующих; скрытые поля `run`/`subnet` и плашка «Из аналитики» заменяются видимыми полями (ссылка «к аналитике» остаётся при выбранном запуске, кнопка «сбросить фильтры»). Подпись «найдено N адресов». `registry_level` скрывает уровни и типы вне области. Сообщение «Ничего не найдено» учитывает все фильтры. +- Ссылки из аналитики (`/registry?run=&subnet=`) продолжают работать. + +### 4. Тесты (минимум) +- `internal/db`: один табличный тест среза — запуск (два запуска одного адреса дают разный результат), направление, протокол (`tcp-22` и `tcp-443` → `tcp`, `tls-443` → `tls`, `tls` на Egress — пусто), статус в области, подсеть + запуск + направление вместе, адрес без проверок в области исключается. +- `internal/httpapi`: 400 на неверные `direction`/`protocol`; новые параметры вместе со старыми. +- `internal/dashboard` (`TestRegistryPageAndDetail`): страница с четырьмя фильтрами, сохранение значений в пагинаторе. +- `TestScaleSmoke6440`: прогон со всеми фильтрами, лимит 10 с. + +## Порядок работы + +1. Утвердить план и пункты «Решения». +2. Субагент (Sonnet 5.5, Medium effort) пишет код: п.1 → п.2 → п.3 → п.4. +3. `go build ./... && go vet ./... && go test ./...`; `EXPLAIN QUERY PLAN` запросов среза — по `idx_checks_run` и `idx_checks_registry_cycle`; страница на 6440 адресов — не медленнее текущей более чем в разы. +4. Проверка на стенде (`docs/LOCAL_E2E.md`): сверить цифры запуска в `/registry` со страницей «Аналитика» этого же запуска. +5. Summary в `docs/changes/…-summary.md`; обновить `README.md` (строки «Реестр», журнал изменений), `docs/API.md`, `docs/USAGE.md`; обновить граф `/graphify . --update`. + +## Риски + +- Для данных, накопленных до появления запусков, запуски выделены по паузам (миграция 0011) — граница приблизительная. +- Если история циклов обрезана (`history_retention_cycles`), проверки старого запуска могут быть удалены: адрес остаётся в `run_results`, но уровни пусты. Показывать «—» и подсказку. +- Статус в области и вердикт могут расходиться (вердикт учитывает недостающие результаты). Подсказка возле фильтра. +- Подзапрос цикла на адрес при 6440+ строк: проверить планом; при деградации заменить одним агрегирующим запросом по `checks` с `GROUP BY registry_id`. diff --git a/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-summary.md b/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-summary.md new file mode 100644 index 0000000..00033d6 --- /dev/null +++ b/docs/changes/2026-10-06_12-12_registry-subnet-direction-protocol-filters-summary.md @@ -0,0 +1,37 @@ +# Итог: фильтры «Запуск», «Подсеть», «Направление», «Протокол» в «Реестре» + +План: [2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md](2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md). +Статус: код написан и проверен (gofmt, build, vet, test, замеры на копии данных стенда); стенд **не пересобирался**, страница в браузере не открывалась. + +## Что изменено + +- **БД** (`internal/db/queries_registry.go`): `RegistryFilter.Level` и `Family`; общий «срез» данных адреса (запуск, направление, протокол). С запуском список берётся из `run_results`, цикл и вердикт — оттуда же, проверки — по `checks.run_id`. Без запуска — последний цикл, как раньше. Направление и протокол сужают проверки цикла; статус в такой области считается по этим проверкам. `subnetIDs` передаёт id одним JSON-параметром (`json_each`) — лимит параметров SQLite больше не угрожает крупной подсети. Неверные значения — `ErrValidation`. +- **API** (`internal/httpapi`): параметры `direction` (`egress|ingress`) и `protocol` (`icmp|tcp|ssh|https|tls`) в `GET /admin/registry`, `400` на неверные; в ответ страницы добавлено поле `run`. `docs/API.md` обновлён. +- **Дашборд** (`internal/dashboard`): в форме `/registry` четыре новых поля — «Запуск» (список запусков из аналитики), «Подсеть» (ввод + список настроенных подсетей), «Направление», «Протокол»; значения в адресной строке и в ссылках пагинатора. Столбец «Результат в запуске N», строка «Найдено адресов», скрытие уровней вне области, ссылка «к аналитике запуска N», подсказка про срез и `tls`. Старая плашка «Из аналитики» заменена видимыми полями; ссылки из аналитики работают. +- **Документы**: `API.md`, `USAGE.md`, `README.md`. + +## Исправлено при ревью + +1. Запрос списков запусков и подсетей делался при каждом обновлении таблицы через htmx — теперь только при полной загрузке страницы (как на `/ips`). +2. Ссылки «сбросить фильтры» и «к аналитике» не обновлялись при htmx-замене таблицы: первая стала постоянной, вторая перенесена в обновляемую область (видна и при пустой выдаче). +3. Тест `TestRegistryDrillDownFromAnalytics` приведён к новой форме; длинный комментарий в `fillRegistryLevels` перенесён. + +## Проверки + +- `gofmt -l` пусто; `go build ./...`, `go vet ./...`, `go test -count=1 ./...` — все пакеты `ok`. +- Новые тесты: табличный `TestRegistryPageSlice` (запуск, направление, протокол, статус в области, подсеть, валидация), параметры и `400` в API, четыре фильтра в дашборде, `TestScaleSmoke6440` со всеми фильтрами. +- `EXPLAIN QUERY PLAN`: запросы среза идут по `idx_checks_run` и `idx_checks_registry_cycle`, полного перебора `checks` нет. +- Замер на копии БД стенда (6498 адресов, 1,07 млн проверок, снимок `VACUUM INTO`): страница 50 строк — 46–160 мс без направления/протокола, 350–730 мс с ними; подсеть, запуск и все фильтры вместе — 63 мс. +- Сверка с SQL вручную: «запуск 7 + Egress + https + fail» и «tcp + fail» — по 1 адресу, совпало с прямым запросом. +- В данных стенда проверок `tls` нет (входящие порты не включают 443), поэтому «tls» там даёт пустой список — это ожидаемо. + +## Особенности и замечания + +- Тест `TestUpsertCheckIfOpenSetsRecordedAt` (`internal/db`, не затронут изменением) один раз упал при параллельной нагрузке: он ждёт 5 мс по часам. Отдельно и в последующих прогонах проходит. Стоит увеличить паузу отдельным изменением. +- Пилюля вердикта в строке остаётся общим вердиктом даже при направлении/протоколе; статус-фильтр в области может с ним расходиться (подсказка в форме). +- Статус `cancelled` вместе с направлением или протоколом даёт пустой список. +- Неизвестный `run` в URL даёт пустую таблицу и выбранный пункт «Запуск N». + +## Выкладка + +Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard`; миграций нет. Выкладку делать при пустой очереди (`docs`/процедура пересборки стенда). diff --git a/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md b/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md new file mode 100644 index 0000000..4bcaff6 --- /dev/null +++ b/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md @@ -0,0 +1,81 @@ +# План: выпадающий список подсетей и чарт «успешные проверки по целям / площадкам» в «Реестре» + +Редакция 2 (решения по вопросам подтверждены пользователем). + +Продолжение [2026-10-06_12-12_registry-subnet-direction-protocol-filters](2026-10-06_12-12_registry-subnet-direction-protocol-filters-plan.md). Фильтры работают; улучшаем удобство и восприятие. + +## Задача + +1. **Подсеть** выбирается из выпадающего списка (сейчас — поле ввода с подсказками). +2. Когда выбраны **направление и протокол** (например, Egress + https), над таблицей показывается чарт «количество успешных проверок» по каждой цели 1…N: + - **Egress** — в разрезе целей; + - **Ingress** — в разрезе площадок (та же логика). +3. Строка чарта кликабельна и открывает список адресов с детализацией. +4. Подход переиспользуем из «Аналитика → Egress по целям». + +## Что уже есть (граф + код) + +- Аналитика: блок «Egress по целям» — строки `an-bar-row` (название, полоса `an-track`/`an-fill`, число и процент), вкладки по типу проверки. Диалог со списком адресов (`analytics-dialog.js`, шаблон `analytics_dialog`, «Копировать», «Скачать CSV», подсказки при наведении) подключается страницей и уже обслуживает списки по запросу `load(url)` → `fill(...)`. Стили — `static/analytics.css`. +- Данные в БД: в `checks` для egress `target` — адрес цели (`https://github.com`), `source='egress'`. Для ingress `source = inbound-site-N` (площадка), а `target` — сам проверяемый адрес, поэтому ingress группируется по `source`, а не по `target`. Имена площадок берутся по индексу (как `SiteNames` в аналитике), без имени — `site-N`. +- Срез данных (запуск / последний цикл, направление, протокол, статус, подсеть) уже реализован в `internal/db/queries_registry.go` (`registrySlice`, `scopeFrom`). Чарт строится по тому же срезу и тому же набору адресов, что и таблица. +- Список настроенных подсетей уже грузится в форму (`GetSubnets`), 45 штук на стенде. +- Таблица `/registry` обновляется через htmx (`#registry-table-wrap`). + +## Решения (подтверждены пользователем) + +1. **Когда показывать чарт:** выбраны **и** направление, **и** протокол. С одним направлением вместо чарта — короткая подсказка «Выберите протокол, чтобы увидеть распределение по целям/площадкам». +2. **Набор адресов — все под текущим фильтром** (запуск, подсеть, статус, поиск), а не только текущая страница. Чарт совпадает с «Найдено адресов: N». Чарт строится в разрезе проверок выбранного типа в рамках проверочного цикла среза: цикл адреса в выбранном запуске или его последний цикл. +3. **Что считается:** число записанных проверок выбранного типа в проверочном цикле среза: `успешных из всего`. Полоса — доля успешных; подпись: `1 234 из 1 250 · 98,7%`. У tcp и tls на ingress у адреса может быть несколько проверок на площадку (порты 22 и 443), поэтому считаются именно проверки, а не адреса (пояснение в подписи под чартом). +4. **Порядок строк:** **от большего к меньшему** по числу успешных проверок, при равенстве — по названию. Полоса масштабируется по наибольшему значению; доля успешных остаётся в подписи. +5. **Подпись цели:** адрес цели без схемы и без завершающего `/` (`repo.almalinux.org/almalinux`), ключ — исходное значение `target`. Разные пути на одном хосте остаются разными строками. +6. **Клик по строке:** диалог со списком **всех** адресов набора, у которых есть проверки этой цели/площадки, провалы сверху (порядок списка не связан с порядком чарта). Столбцы: Адрес, Результат (`успешно`/`провал`), Тип проверки (`https`, `tcp-22`…), Цель или Площадка, Валидатор (для egress), Задержка (мс), Детали (ошибка), Проверено. Сверху — счётчики «успешно N · провал M». Доступны «Копировать» и «Скачать CSV». +7. **Подсеть в списке** (ручной ввод убирается, решение подтверждено): варианты — «Все подсети» + настроенные подсети (`CIDR — метка`). Ручной ввод убирается. Подсеть из URL (переход из аналитики), которой нет в списке, добавляется отдельным выбранным пунктом. Если подсети не настроены — список недоступен, рядом ссылка на `/settings` с их настройкой. +8. **Egress + ssh/tcp/tls** (таких проверок нет): чарт показывает «Для этого сочетания проверок нет», как и пустая таблица. + +## Изменения + +### 1. БД (`internal/db`) +- Выделить сборку условий `FROM`/`WHERE` из `ListRegistryPage` в функцию, которую используют и список, и новая выборка (поведение списка не меняется; существующие тесты — страховка). +- Новый файл `queries_registry_breakdown.go`: + - `RegistryBreakdown(ctx, filter) (*Breakdown, error)`: требует `Level` и `Family` (иначе `ErrValidation`); один запрос `GROUP BY` по `target` (egress) или `source` (ingress) поверх проверок среза: `COUNT(*)`, `SUM(success)`. Возвращает группу (`target`|`site`), число адресов, строки `{key, total, ok}`. + - `RegistryBreakdownList(ctx, filter, key)`: строки проверок выбранного ключа с адресом, типом, валидатором, задержкой, деталью, временем; провалы сверху. +- Индексы `idx_checks_run` и `idx_checks_registry_cycle` уже подходят; проверить `EXPLAIN QUERY PLAN`. + +### 2. HTTP API (`internal/httpapi`) +- `GET /api/v1/admin/registry/breakdown` — те же параметры, что у реестра (`q`, `last_result`, `run`, `subnet`, `direction`, `protocol`); без `direction` или `protocol` — `400`. Ответ: `{group, direction, protocol, run, addresses, rows:[{key,label,total,ok}]}`; имена площадок подставляются здесь. +- `GET /api/v1/admin/registry/breakdown/list?…&key=` — таблица `{columns, rows}`; `format=csv` — файл. Неизвестный ключ — `404`. +- Маршруты — в таблицу маршрутов (`TestRouteTableIsClassified`: admin-доступ). `docs/API.md` — описание и примеры. + +### 3. Дашборд (`internal/dashboard`) +- `client.go`: `GetRegistryBreakdown`, `GetRegistryBreakdownList`, `…CSV`. +- `handlers_registry.go`: при выбранных направлении и протоколе запросить чарт и передать в шаблон; ошибка чарта показывается в самом блоке, страница не ломается. Прокси-маршруты `GET /registry/breakdown/list` и `GET /registry/breakdown/csv` (как `analytics/lists`). +- `templates/registry.html`: + - поле «Подсеть» → `` с выбранным пунктом и пунктом из URL; прокси списка. +- `TestScaleSmoke6440`: чарт и список на 6440 адресов в пределах лимита. +- Правки существующих тестов, где проверялось поле ввода подсети. + +## Порядок работы + +1. Утвердить план и «Решения». +2. Субагент (Sonnet 5.5, Medium effort) пишет код и тесты: п.1 → п.2 → п.3 → п.4. +3. Ревью, `gofmt`, `go build ./... && go vet ./... && go test -count=1 ./...`, `EXPLAIN QUERY PLAN`. +4. Проверка на тестовом стенде `civ-test` (порты 18081/18091): пересобрать образы тега `filters`, `docker compose up -d` в `/opt/lvraid/claude/civ-teststand`, снять замеры на копии боевой БД. Боевые контейнеры не трогаем. +5. Summary в `docs/changes/…-summary.md`; обновить `README.md`, `docs/API.md`, `docs/USAGE.md`; обновить граф `/graphify . --update`. + +## Риски + +- Объём списка: до 6–7 тысяч адресов в диалоге, как в аналитике; для CSV — серверная выгрузка. +- Время ответа чарта на 1 млн проверок: ожидается долей секунды (один `GROUP BY` по срезу); проверить замером на копии БД стенда. При деградации — не считать чарт, пока не выбраны оба фильтра (уже предусмотрено). +- Названия площадок и целей берутся из текущей конфигурации: удалённая площадка отображается как `site-N`. +- Незавершённый запуск даёт частичные данные — как и таблица. +- Подсеть только из списка: чтобы отфильтровать произвольный CIDR, подсеть нужно добавить в настройки (ссылка рядом с полем). diff --git a/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-summary.md b/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-summary.md new file mode 100644 index 0000000..738c64f --- /dev/null +++ b/docs/changes/2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-summary.md @@ -0,0 +1,36 @@ +# Итог: список подсетей и чарт «успешные проверки по целям / площадкам» в «Реестре» + +План: [2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md](2026-10-06_13-11_registry-subnet-select-and-breakdown-chart-plan.md). +Статус: код написан и проверен (gofmt, build, vet, test; страница в headless-браузере на тестовом стенде `civ-test` с копией боевой БД). Боевой стенд **не пересобирался**. + +## Что изменено + +- **БД** (`internal/db`): сборка `FROM`/`WHERE` фильтра вынесена из `ListRegistryPage` в `registryFilterSQL` (список и чарт используют одно условие). Новый `queries_registry_breakdown.go`: `RegistryBreakdown` (один `GROUP BY` по `target` для egress или по `source` для ingress, число адресов под фильтром) и `RegistryBreakdownList` (проверки одной строки, провалы сверху). `checks` присоединяется через `CROSS JOIN`: без статистики планировщик выбирал полный перебор `checks`. +- **API** (`internal/httpapi`): `GET /admin/registry/breakdown` и `GET /admin/registry/breakdown/list` (`format=csv`); общий разбор фильтра для трёх ручек; подписи целей (без схемы и `/`) и площадок (имя, иначе `site-N`); строки от большего числа успешных к меньшему. `400` без направления или протокола, `404` на неизвестный ключ. `docs/API.md` обновлён. +- **Дашборд** (`internal/dashboard`): «Подсеть» — ``), «Направление», «Протокол», «сбросить фильтры»; изменение поля переходит по URL. Блок чарта (`registry_breakdown`) над плитками. Подключить `registry.js`. +- `static/analytics.js`: фокус по направлению и протоколу (скрытие блоков, выбор вкладки типа, столбец типа в таблице площадок); ссылка «Открыть в реестре» из подсетей и матрицы переносит направление и протокол; подпись «Запуск N · подсеть …» в диалоге. +- Реестр: «к аналитике запуска N» передаёт `subnet`, `direction`, `protocol`; в обратную сторону ссылки аналитики уже передают запуск и подсеть. +- Тема, мобильная раскладка: поля фильтров по образцу реестра (в колонке без растяжения по высоте). + +### 4. Тесты (минимум) +- `internal/analytics`: один тест — `Compute` с подсетью: число адресов, плитки, цели и площадки считаются по подмножеству; списки совпадают с плитками; пустая подсеть — прежний результат. +- `internal/httpapi`: `subnet` в отчёте и списке, `400` на неверный CIDR, ключ кэша не смешивает подсети. +- `internal/dashboard`: страница с фильтрами (select подсети, выбранные значения, ссылки ◀ ▶ с параметрами), чарт при направлении и протоколе и его отсутствие без них, прокси списков с `subnet`. Правка существующих тестов страницы. + +## Порядок работы + +1. Утвердить план и «Решения». +2. Субагент (Sonnet 5.5, Medium effort) пишет код и тесты: п.1 → п.2 → п.3 → п.4. +3. Ревью, `gofmt`, `go build ./... && go vet ./... && go test -count=1 ./...`; замеры на копии БД. +4. Проверка на тестовом стенде `civ-test` (18081/18091), в том числе в браузере (светлая и тёмная темы, телефон, клики по чарту и плиткам при выбранной подсети). Боевые контейнеры не трогаем. +5. Summary в `docs/changes/…-summary.md`; обновить `README.md`, `docs/API.md`, `docs/USAGE.md`; обновить граф `/graphify . --update`. + +## Риски + +- Подмножество по подсети меняет смысл плиток «всего адресов» — это нужно показывать в подписи страницы («подсеть X: N адресов из M в запуске»). +- Статистики направления и протокола не пересчитываются: при `Egress` плитки ingress остаются прежними, но блок скрыт. Это объяснить подсказкой, чтобы не путать с чартом, который считает именно выбранные проверки. +- Расхождение чарта и плиток возможно по определению: чарт считает проверки цикла запуска (включая пришедшие позже), плитки аналитики — факты тоже, но вердикты берут из запуска. В реестре разница уже описана; на аналитике нужна та же подсказка. +- Память кэша на подсети: ограничение числа записей. +- Подсеть без адресов в запуске: показать «В запуске нет адресов этой подсети» вместо пустых блоков. diff --git a/docs/changes/2026-10-06_13-55_analytics-filters-summary.md b/docs/changes/2026-10-06_13-55_analytics-filters-summary.md new file mode 100644 index 0000000..b3937a4 --- /dev/null +++ b/docs/changes/2026-10-06_13-55_analytics-filters-summary.md @@ -0,0 +1,36 @@ +# Итог: фильтры подсеть / направление / протокол и чарт на странице «Аналитика» + +План: [2026-10-06_13-55_analytics-filters-plan.md](2026-10-06_13-55_analytics-filters-plan.md). +Статус: код написан и проверен (gofmt, build, vet, test; страница в headless-браузере на тестовом стенде `civ-test` с копией боевой БД). Боевой стенд **не пересобирался**. + +## Что изменено + +- **Аналитика** (`internal/analytics`): `Input.Subnet`; `Compute` оставляет адреса запуска внутри подсети (вложенные входят), отчёт и списки считаются по одному подмножеству; в `Report` блок `scope {subnet, run_addresses}` для подписи «N адр. из M». `Load(ctx, d, runID, subnet)` читает из БД только проверки адресов подсети. +- **БД**: `EachRunCheck` принимает список адресов (один JSON-параметр `json_each`, `nil` — все). +- **API** (`internal/httpapi`): параметр `subnet` в `GET /admin/analytics/runs/{id}` и `…/lists/{kind}` (JSON и CSV); неверный CIDR — `400`. Кэш — по запуску и подсети, не больше 16 записей (вытесняется давно не запрашивавшаяся). Сравнение запусков — без подсети. `docs/API.md` обновлён. +- **Дашборд** (`internal/dashboard`): форма фильтров под выбором запуска (подсеть — `Все подсети`, `href="/settings">добавить в настройках`} { + if !strings.Contains(page, want) { + t.Fatalf("no subnets configured: expected %q in:\n%s", want, page) + } + } + + fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}, {CIDR: "10.0.0.0/8"}}} + page = get(t, ts, "/registry?subnet=10.0.0.0/8") + for _, want := range []string{``, ``} { + if !strings.Contains(page, want) { + t.Fatalf("expected %q in:\n%s", want, page) + } + } + if strings.Contains(page, "disabled") || strings.Contains(page, "нет в списке") { + t.Fatalf("a configured subnet must neither disable the selector nor be added again:\n%s", page) + } +} + +// The chart above the registry table: only with a direction and a protocol (a +// hint for the missing one), rows as control-api sorted them, an error inside +// the block that leaves the table in place; the list proxies forward the filter. +func TestRegistryBreakdownChartAndProxy(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.registry["9.9.9.9"] = registryItem{IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now} + fake.breakdown = registryBreakdown{Group: "site", Direction: "ingress", Protocol: "tls", Addresses: 1, Rows: []breakdownRow{ + {Key: "inbound-site-2", Label: "rxspb", Total: 1250, OK: 1234}, {Key: "inbound-site-1", Label: "rxmsk", Total: 617, OK: 617}, + }} + ts := newTestServer(t, caURL) + + page := get(t, ts, "/registry") + if strings.Contains(page, `id="registry-breakdown"`) || strings.Contains(page, "Выберите") { + t.Fatalf("no chart and no hint without filters:\n%s", page) + } + page = get(t, ts, "/registry?direction=ingress") + if !strings.Contains(page, "Выберите протокол, чтобы увидеть распределение по площадкам") || strings.Contains(page, `id="registry-breakdown"`) { + t.Fatalf("a direction alone gives a hint:\n%s", page) + } + + page = get(t, ts, "/registry?direction=ingress&protocol=tls&run=3&subnet=9.9.9.0/24") + for _, want := range []string{ + "Успешные проверки по площадкам · Ingress tls", `data-bd-key="inbound-site-2"`, `data-bd-label="rxspb"`, + "1\u00a0234 из 1\u00a0250 · 98,7%", "617 из 617 · 100%", `style="width:100.0%"`, `style="width:50.0%"`, + `data-slice="запуск 3 · подсеть 9.9.9.0/24"`, `data-qs="direction=ingress&protocol=tls&run=3&subnet=9.9.9.0%2F24"`, "Адресов под фильтром: 1", + "9.9.9.9", // the table is still there + } { + if !strings.Contains(page, want) { + t.Fatalf("expected %q in:\n%s", want, page) + } + } + if strings.Index(page, "rxspb") > strings.Index(page, "rxmsk") { + t.Fatalf("rows must keep the order control-api gave:\n%s", page) + } + fake.mu.Lock() + req := fake.breakdownReqs[len(fake.breakdownReqs)-1] + fake.mu.Unlock() + for _, want := range []string{"direction=ingress", "protocol=tls", "run=3", "subnet=9.9.9.0%2F24"} { + if !strings.Contains(req, want) { + t.Fatalf("breakdown request %q lacks %s", req, want) + } + } + + fake.breakdown.Rows = nil + if page = get(t, ts, "/registry?direction=egress&protocol=tls"); !strings.Contains(page, "Для этого сочетания проверок нет") { + t.Fatalf("an empty chart says so:\n%s", page) + } + fake.breakdownStatus = http.StatusInternalServerError + page = get(t, ts, "/registry?direction=egress&protocol=https") + if !strings.Contains(page, "Не удалось получить распределение") || !strings.Contains(page, "9.9.9.9") { + t.Fatalf("a chart error is shown in its block and the table stays:\n%s", page) + } + + // The list proxies: filter and key reach control-api; the filter is checked. + fake.breakdownList = `{"columns":["Адрес"],"rows":[["1.2.3.4"]]}` + for path, want := range map[string]int{ + "/registry/breakdown/list?direction=ingress&protocol=tls&key=inbound-site-1&run=3&status=fail": http.StatusOK, + "/registry/breakdown/csv?direction=ingress&protocol=tls&key=inbound-site-1": http.StatusOK, + "/registry/breakdown/list?direction=ingress&key=inbound-site-1": http.StatusBadRequest, // no protocol + "/registry/breakdown/csv?direction=ingress&protocol=tls": http.StatusBadRequest, // no key + } { + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want) + } + if strings.Contains(path, "/csv") && want == http.StatusOK && + (!strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || !strings.Contains(resp.Header.Get("Content-Disposition"), "registry_ingress_tls_rxmsk.csv")) { + t.Fatalf("csv: %v %s", resp.Header, body) + } + if strings.Contains(path, "/list") && want == http.StatusOK && !strings.Contains(string(body), `"1.2.3.4"`) { + t.Fatalf("list: %s", body) + } + } + fake.mu.Lock() + var list string + for _, r := range fake.breakdownReqs { + if strings.Contains(r, "/breakdown/list") && !strings.Contains(r, "format=csv") { + list = r + } + } + fake.mu.Unlock() + for _, want := range []string{"key=inbound-site-1", "run=3", "last_result=fail", "direction=ingress", "protocol=tls"} { + if !strings.Contains(list, want) { + t.Fatalf("list request %q lacks %s", list, want) + } + } +} diff --git a/internal/dashboard/handlers_registry.go b/internal/dashboard/handlers_registry.go index 21d2554..25bdf54 100644 --- a/internal/dashboard/handlers_registry.go +++ b/internal/dashboard/handlers_registry.go @@ -1,6 +1,7 @@ package dashboard import ( + "errors" "net/http" "net/netip" "net/url" @@ -8,13 +9,28 @@ import ( "strings" ) +// Values of the registry's direction and protocol filters; same as +// db.RegistryFilter's Level and Family. +var ( + registryDirections = []string{"egress", "ingress"} + registryProtocols = []string{"icmp", "tcp", "ssh", "https", "tls"} +) + type registryPageData struct { PageData Items []registryItem Query string StatusFilter string - Run int64 // drill-down from the analytics page + Run int64 // data slice: the address's cycle in this run (also the drill-down from analytics) Subnet string + Direction string + Protocol string + Protocols []string + Scoped bool // direction or protocol is set + Runs []runOption + SubnetOptions []subnetOption + Breakdown *breakdownView // nil unless a direction or protocol is chosen + AnalyticsURL string // the analytics page of Run with the same subnet, direction and protocol Page, PerPage int Total int Pager pagerData @@ -31,25 +47,18 @@ type registryDetailData struct { // record that survives an address being deleted from /ips and later // re-added. See internal/db/migrations/0007_ip_registry.sql. Optional // ?q=&status= query params narrow the list by address substring and by -// LastResult, and ?page=&per_page= select a page — all applied server-side -// (control-api's ListRegistryPage), so only the visible rows are transferred. +// LastResult, ?run=&subnet=&direction=&protocol= by run, subnet and the +// direction/protocol of the checks, and ?page=&per_page= select a page — all +// applied server-side (control-api's ListRegistryPage), so only the visible +// rows are transferred. The run and subnet choices come from the analytics +// run list and the configured subnets; if either list is unavailable the +// filters still work, just without those choices. func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { - q := strings.TrimSpace(r.URL.Query().Get("q")) - status := r.URL.Query().Get("status") - if !containsStr(ipResults, status) { - status = "" - } + query := parseRegistryQuery(r) + q, status, run, subnet, direction, protocol := query.Q, query.LastResult, query.Run, query.Subnet, query.Direction, query.Protocol perPage := parsePerPage(r.URL.Query().Get("per_page")) page := parsePage(r.URL.Query().Get("page")) - run, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64) - if run < 0 { - run = 0 - } - subnet := strings.TrimSpace(r.URL.Query().Get("subnet")) - if _, err := netip.ParsePrefix(subnet); err != nil { - subnet = "" - } - query := registryQuery{Q: q, LastResult: status, Run: run, Subnet: subnet, Limit: perPage, Offset: (page - 1) * perPage} + query.Limit, query.Offset = perPage, (page-1)*perPage res, err := s.CA.ListRegistryPage(r.Context(), query) if err == nil { @@ -72,9 +81,31 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { if subnet != "" { params.Set("subnet", subnet) } + if direction != "" { + params.Set("direction", direction) + } + if protocol != "" { + params.Set("protocol", protocol) + } + // A filter/pager request from htmx swaps only #registry-table-wrap + // (hx-select), so the run and subnet choices of the form are not needed. + // A history-restore fetch needs the full page. + var runs []analyticsRun + var subnets subnetList + var runsErr, subnetsErr error + if r.Header.Get("HX-Request") != "true" || r.Header.Get("HX-History-Restore-Request") == "true" { + runs, runsErr = s.CA.ListAnalyticsRuns(r.Context()) + subnets, subnetsErr = s.CA.GetSubnets(r.Context()) + } data := registryPageData{ Run: run, Subnet: subnet, + Direction: direction, + Protocol: protocol, + Protocols: registryProtocols, + Scoped: direction != "" || protocol != "", + Runs: registryRunOptions(runs, run), + SubnetOptions: registrySubnetOptions(subnets.Subnets, subnet), Items: res.Items, Query: q, StatusFilter: status, @@ -85,10 +116,223 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { PerPageOptions: perPageOptions, } data.ActiveNav = "registry" + if run > 0 { + data.AnalyticsURL = analyticsURL(run, query) + } + if err == nil { + data.Breakdown = s.registryBreakdown(r, query, params) + err = errors.Join(runsErr, subnetsErr) + } data.Banner = bannerFor(err) s.renderPage(w, r, "registry_page", data) } +// parseRegistryQuery reads the filter of the registry page and of its chart +// requests (?q=&status=&run=&subnet=&direction=&protocol=); a value that is +// not valid is dropped. +func parseRegistryQuery(r *http.Request) registryQuery { + v := r.URL.Query() + q := parseSliceFilter(v) + q.Q, q.LastResult = strings.TrimSpace(v.Get("q")), v.Get("status") + if !containsStr(ipResults, q.LastResult) { + q.LastResult = "" + } + return q +} + +// parseSliceFilter reads the part of the filter that the registry and the +// analytics page share (?run=&subnet=&direction=&protocol=): the data slice +// of a run, a subnet, and the direction and protocol of the checks. A value +// that is not valid is dropped. +func parseSliceFilter(v url.Values) registryQuery { + var q registryQuery + if q.Run, _ = strconv.ParseInt(v.Get("run"), 10, 64); q.Run < 0 { + q.Run = 0 + } + if q.Subnet = strings.TrimSpace(v.Get("subnet")); q.Subnet != "" { + if _, err := netip.ParsePrefix(q.Subnet); err != nil { + q.Subnet = "" + } + } + if q.Direction = v.Get("direction"); !containsStr(registryDirections, q.Direction) { + q.Direction = "" + } + if q.Protocol = v.Get("protocol"); !containsStr(registryProtocols, q.Protocol) { + q.Protocol = "" + } + return q +} + +// subnetOption is one entry of the "Подсеть" selector. +type subnetOption struct { + CIDR, Text string + Selected bool +} + +// registrySubnetOptions lists the configured subnets for the selector as +// "CIDR — label". The chosen subnet is always present and selected: one that is +// not configured (a link from analytics, or the list is unavailable) is added +// as a separate entry. +func registrySubnetOptions(subnets []subnetEntry, chosen string) []subnetOption { + var out []subnetOption + found := false + for _, x := range subnets { + text := x.CIDR + if x.Label != "" { + text += " — " + x.Label + } + out = append(out, subnetOption{CIDR: x.CIDR, Text: text, Selected: x.CIDR == chosen}) + found = found || x.CIDR == chosen + } + if chosen != "" && !found { + out = append(out, subnetOption{CIDR: chosen, Text: chosen + " (нет в списке)", Selected: true}) + } + return out +} + +// breakdownView is the chart "successful checks per target / site" above the +// registry table. With Hint set, it is only a prompt to choose the missing +// filter; with Err set, the chart could not be loaded. +type breakdownView struct { + Title, Scope, Slice, QS string + Hint, Err string + Addresses int + Rows []breakdownRowView +} + +type breakdownRowView struct { + Key, Label, Width, Text, Tip string +} + +// registryBreakdown builds the chart for the page's filter: nothing without a +// direction and a protocol, then a hint for the missing one. params is the +// filter as the page's links carry it; the chart's dialog requests its lists +// with the same query string. +func (s *Server) registryBreakdown(r *http.Request, q registryQuery, params url.Values) *breakdownView { + switch { + case q.Direction == "" && q.Protocol == "": + return nil + case q.Protocol == "": + return &breakdownView{Hint: "Выберите протокол, чтобы увидеть распределение по " + breakdownGroupName(q.Direction) + "."} + case q.Direction == "": + return &breakdownView{Hint: "Выберите направление, чтобы увидеть распределение по целям или площадкам."} + } + v := &breakdownView{Scope: strings.ToUpper(q.Direction[:1]) + q.Direction[1:] + " " + q.Protocol, QS: params.Encode(), Slice: "последний цикл адреса"} + if q.Run > 0 { + v.Slice = "запуск " + strconv.FormatInt(q.Run, 10) + } + if q.Subnet != "" { + v.Slice += " · подсеть " + q.Subnet + } + v.Title = "Успешные проверки по " + breakdownGroupName(q.Direction) + b, err := s.CA.GetRegistryBreakdown(r.Context(), q) + if err != nil { + v.Err = "Не удалось получить распределение: " + err.Error() + return v + } + v.Addresses = b.Addresses + most := 0 + for _, x := range b.Rows { + most = max(most, x.OK) + } + for _, x := range b.Rows { // control-api sorts them, most successful first + width := 0.0 + if most > 0 { + width = float64(x.OK) * 100 / float64(most) + } + ok, total := groupThousands(x.OK), groupThousands(x.Total) + v.Rows = append(v.Rows, breakdownRowView{ + Key: x.Key, Label: x.Label, Width: strconv.FormatFloat(width, 'f', 1, 64), + Text: ok + " из " + total + " · " + pct1(x.OK, x.Total) + "%", + Tip: x.Label + ": успешно " + ok + " из " + total + " проверок. Нажмите, чтобы открыть список адресов", + }) + } + return v +} + +// breakdownGroupName is what the chart groups the checks of a direction by. +func breakdownGroupName(direction string) string { + if direction == "ingress" { + return "площадкам" + } + return "целям" +} + +// pct1 is a/b in percent with at most one decimal and a decimal comma: 98,7. +func pct1(a, b int) string { + if b == 0 { + return "0" + } + s := strconv.FormatFloat(float64(a)*100/float64(b), 'f', 1, 64) + return strings.Replace(strings.TrimSuffix(s, ".0"), ".", ",", 1) +} + +// breakdownQuery reads the request of the chart's list proxies: the page's +// filter, with direction and protocol and the row's key required. +func breakdownQuery(w http.ResponseWriter, r *http.Request) (q registryQuery, key string, ok bool) { + q, key = parseRegistryQuery(r), r.URL.Query().Get("key") + if q.Direction == "" || q.Protocol == "" || key == "" { + http.Error(w, "direction, protocol and key are required", http.StatusBadRequest) + return q, key, false + } + return q, key, true +} + +// handleRegistryBreakdownList proxies the checks behind one row of the chart as JSON. +func (s *Server) handleRegistryBreakdownList(w http.ResponseWriter, r *http.Request) { + q, key, ok := breakdownQuery(w, r) + if !ok { + return + } + out, err := s.CA.GetRegistryBreakdownList(r.Context(), q, key) + if err != nil { + writeProxyError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(out) +} + +// handleRegistryBreakdownCSV proxies the same table as a CSV download. +func (s *Server) handleRegistryBreakdownCSV(w http.ResponseWriter, r *http.Request) { + q, key, ok := breakdownQuery(w, r) + if !ok { + return + } + body, disposition, err := s.CA.GetRegistryBreakdownCSV(r.Context(), q, key) + if err != nil { + writeProxyError(w, err) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + if disposition != "" { + w.Header().Set("Content-Disposition", disposition) + } + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(body) +} + +// registryRunOptions lists the runs for the "Запуск" selector, newest first as +// control-api returns them; a finished run without addresses is hidden. The +// chosen run is always present and selected, even if the list lacks it (the +// history was cleaned up, or the list is unavailable). +func registryRunOptions(runs []analyticsRun, chosen int64) []runOption { + var out []runOption + found := false + for _, x := range runs { + if x.State == "finalized" && x.Addresses == 0 && x.ID != chosen { + continue + } + out = append(out, runOption{ID: x.ID, Label: runLabel(x), Selected: x.ID == chosen}) + found = found || x.ID == chosen + } + if chosen > 0 && !found { + out = append(out, runOption{ID: chosen, Label: "Запуск " + strconv.FormatInt(chosen, 10), Selected: true}) + } + return out +} + // handleRegistryDetail shows one address's full retained check history // across every cycle it has ever run, not just the current attempt — see // ip_detail_content in ip_detail.html for the attempt-scoped equivalent. diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go index 246dac3..c087325 100644 --- a/internal/dashboard/handlers_test.go +++ b/internal/dashboard/handlers_test.go @@ -1,6 +1,7 @@ package dashboard import ( + "fmt" "reflect" "strings" "testing" @@ -632,6 +633,53 @@ func TestRegistryPageAndDetail(t *testing.T) { if !strings.Contains(notFound, "alert-warning") { t.Fatalf("expected client error banner for unknown registry address, got:\n%s", notFound) } + + // The four filters: the run and subnet choices come from control-api, the + // values reach it and the pager, unknown direction/protocol are dropped, and + // with a direction/protocol only the levels that have checks are shown. + fake.runs = []analyticsRun{fakeRun(2, "open", 120, 40), fakeRun(1, "finalized", 900, 300)} + fake.subnets = subnetList{Subnets: []subnetEntry{{CIDR: "9.9.9.0/24", Label: "Офис"}}} + fake.registry["9.9.9.8"] = registryItem{ + IPAddress: "9.9.9.8", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass", + LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}}, + } + for i := 0; i < 30; i++ { // a second page for the pager + ip := fmt.Sprintf("9.9.9.%d", 100+i) + fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "pass", + LastCycleID: 1, Ingress: levelResult{Total: 1, OK: 1, ByType: []typeStat{{"tls", 1, 1}}}} + } + page = get(t, ts, "/registry?run=1&subnet=9.9.9.0/24&direction=ingress&protocol=tls&per_page=25") + fake.mu.Lock() + last := fake.registryQueries[len(fake.registryQueries)-1] + fake.mu.Unlock() + for _, want := range []string{"run=1", "subnet=9.9.9.0%2F24", "direction=ingress", "protocol=tls"} { + if !strings.Contains(last, want) { + t.Fatalf("control-api request %q lacks %s", last, want) + } + } + for _, want := range []string{ + ``, + `value="ingress" selected`, `value="tls" selected`, "Результат в запуске 1", `level-name">Ingress`, + } { + if !strings.Contains(page, want) { + t.Fatalf("expected %q in the filtered registry page, got:\n%s", want, page) + } + } + if strings.Contains(page, `level-name">Egress`) { + t.Fatalf("the Egress level must be hidden when only ingress checks are selected, got:\n%s", page) + } + next := pagerLink(t, page, "next") + if next == nil || next.Query().Get("run") != "1" || next.Query().Get("subnet") != "9.9.9.0/24" || + next.Query().Get("direction") != "ingress" || next.Query().Get("protocol") != "tls" { + t.Fatalf("pager link lost the filters: %v", next) + } + get(t, ts, "/registry?direction=sideways&protocol=udp") + fake.mu.Lock() + last = fake.registryQueries[len(fake.registryQueries)-1] + fake.mu.Unlock() + if strings.Contains(last, "direction=") || strings.Contains(last, "protocol=") { + t.Fatalf("unknown direction/protocol must be dropped: %q", last) + } } // TestRegistryPageShowsEgressIngressLevels proves the list shows, under the diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go index 46901d9..0b61651 100644 --- a/internal/dashboard/routes.go +++ b/internal/dashboard/routes.go @@ -28,6 +28,8 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /registry", s.handleRegistryPage) mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail) + mux.HandleFunc("GET /registry/breakdown/list", s.handleRegistryBreakdownList) + mux.HandleFunc("GET /registry/breakdown/csv", s.handleRegistryBreakdownCSV) mux.HandleFunc("GET /analytics", s.handleAnalyticsPage) mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList) diff --git a/internal/dashboard/static/analytics.css b/internal/dashboard/static/analytics.css index 4a60904..ebde57d 100644 --- a/internal/dashboard/static/analytics.css +++ b/internal/dashboard/static/analytics.css @@ -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; } diff --git a/internal/dashboard/static/analytics.js b/internal/dashboard/static/analytics.js index c86e784..3094441 100644 --- a/internal/dashboard/static/analytics.js +++ b/internal/dashboard/static/analytics.js @@ -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 = 'Площадка' + T.types.map(function (t) { return '' + esc(t) + ''; }).join('') + '' + + 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 = 'Площадка' + T.types.filter(shown).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) + '% провал'; + return shown(st, i) ? '' + pct1(st.total - st.ok, st.total) + '% провал' : ''; }).join('') + ''; }).join('') + ''; } @@ -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); }); diff --git a/internal/dashboard/static/dashboard.css b/internal/dashboard/static/dashboard.css index cbe40c7..7195ffd 100644 --- a/internal/dashboard/static/dashboard.css +++ b/internal/dashboard/static/dashboard.css @@ -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; } diff --git a/internal/dashboard/static/registry.js b/internal/dashboard/static/registry.js new file mode 100644 index 0000000..9ae6902 --- /dev/null +++ b/internal/dashboard/static/registry.js @@ -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: '
успешно ' + A.fmt(ok) + 'провал ' + A.fmt(l.rows.length - ok) + '
', + 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); + }); +})(); diff --git a/internal/dashboard/templates/analytics.html b/internal/dashboard/templates/analytics.html index e719c38..3fdf77b 100644 --- a/internal/dashboard/templates/analytics.html +++ b/internal/dashboard/templates/analytics.html @@ -20,6 +20,7 @@ {{if .HasRun}} + {{end}} @@ -38,22 +39,62 @@

Запусков проверки пока нет. Они появляются, когда адреса ставятся в очередь на странице «Очередь IP» или запускается автоматический цикл.

{{else}} -
+
+
+{{/* a change goes to the page of the new filter; empty values stay out of the address */}} +
{{if .PrevURL}}◀{{else}}◀{{end}} - {{range .Runs}} {{end}} {{if .NextURL}}▶{{else}}▶{{end}} -{{if .HasRun}}Сравнить с другим запуском{{end}} +{{if .HasRun}}Сравнить с другим запуском{{end}}
-{{if .HasRun}}

{{else}}

Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.

{{end}} +{{if .HasRun}} +
+
+ + +{{if not .SubnetOptions}}Подсети не настроены — добавить в настройках{{end}} +
+
+ + +
+
+ + +
+{{if .Filtered}}сбросить фильтры{{end}} +
+{{end}} +
+{{if .HasRun}}

+

Подсеть пересчитывает все блоки страницы по её адресам. Направление и протокол задают фокус: скрывают блоки другого уровня и выбирают тип проверки, а вердикты и «Качество данных» остаются по всем проверкам запуска. С направлением и протоколом сразу показывается чарт.{{if .Breakdown}} Чарт считает проверки цикла запуска, в том числе пришедшие после вердикта, а плитки ниже берут вердикты запуска, поэтому числа могут расходиться.{{end}}

+{{else}}

Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.

{{end}}
{{end}} {{if .HasRun}} +{{with .Breakdown}}{{template "registry_breakdown" .}}{{end}} + +
@@ -84,7 +125,7 @@

Строка ведёт в «Реестр» с фильтром по запуску и подсети.

-
+

Egress по целям

@@ -98,7 +139,7 @@

-
+

Ingress по площадкам

@@ -109,12 +150,13 @@
-
+

Валидаторы: доля провалов egress https

Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.

+
{{template "analytics_dialog"}} {{end}} diff --git a/internal/dashboard/templates/registry.html b/internal/dashboard/templates/registry.html index fae5921..107cbc7 100644 --- a/internal/dashboard/templates/registry.html +++ b/internal/dashboard/templates/registry.html @@ -1,7 +1,9 @@ {{define "registry_page"}} -{{template "html_head" .}} +{{template "html_head" .}} + +
@@ -15,6 +17,10 @@ +{{/* The list behind a row of the chart opens in the analytics dialog. */}} +
{{template "analytics_dialog"}}
+ + {{end}} @@ -26,13 +32,6 @@ Глубина хранимой истории на адрес настраивается на странице настроек.

-{{if .Run}}{{end}} -{{if .Subnet}}{{end}} -{{if or .Run .Subnet}}
-Из аналитики:{{if .Run}} запуск {{.Run}}{{end}}{{if .Subnet}} · подсеть {{.Subnet}}{{end}} -сбросить фильтр -{{if .Run}}к аналитике{{end}} -
{{end}}
@@ -41,6 +40,52 @@ hx-include="#registry-filter" hx-trigger="input changed delay:300ms" hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
+
+ + +
+
+ + +{{if not .SubnetOptions}}Подсети не настроены — добавить в настройках{{end}} +
+
+ + +
+
+ + +