diff --git a/README.md b/README.md index 5173604..4e99c24 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ docs/ документация и планы доработок - Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками. - Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам. +**Запуски и аналитика** +- Запуск — одна партия проверок: открывается, когда адрес попадает в пустую или обработанную очередь, принимает всё, что добавлено или перепроверено, пока открыт, и завершается, когда у всех его адресов есть итог. Перепроверка после завершения открывает новый запуск; результаты запусков не пересекаются. +- Страница «Аналитика» показывает один завершённый запуск по фактическим проверкам: показатели, причины `partial`, подсети (список — в настройках), цели и типы проверок, площадки, классы ошибок, валидаторы; списки адресов выгружаются в CSV. + **Реестр** - Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление. - История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся. @@ -122,7 +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` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` | +| Реестр | `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\|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` | @@ -162,6 +167,7 @@ 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 | | `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок | | `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок | - Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается. @@ -202,6 +208,7 @@ scripts/run-local-e2e.sh # сквозной прог | Дата | Веха | Документ | |---|---|---| +| 2026-10-03 | Раздел «Аналитика»: запуски проверки (миграция `0011`), показатели и списки адресов по запуску, подсети, CSV; сайдбар: связь с control-api и выход наверху, группы разделов; фильтры реестра по запуску и подсети | [план](docs/changes/2026-10-03_16-39_analytics-section-plan.md) · [итог](docs/changes/2026-10-03_18-41_analytics-section-summary.md) · [макет](docs/mockups/analytics-mockup.html) · [USAGE](docs/USAGE.md#аналитика-запусков) · [API](docs/API.md#аналитика-запусков) | | 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) | | 2026-10-03 | Реестр: последний результат по уровням Egress и Ingress, «успешно из всего» и разбивка по типам проверок (поля `egress`, `ingress`, `last_cycle_id` в `GET /admin/registry`) | [план](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-plan.md) · [итог](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) | | 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) | diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index db7a873..806d377 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,4 +1,4 @@ -657d24d49686f04d3e9d9e431bb56ba8d7e2b3f6d865059de8315eceee6df53a control-api +7a5227edddb89763f8db8ea56be5a1703626998498c3969b69272bfdb9d670b9 control-api 9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent 3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober -8c636afb4f68b461376a1f5a010faa72cc0b0f17adf55611e9d924936d6fe115 admin-dashboard +1e5c0c3e2857aa2e856d89a6d72db7f402179b9d912177040c6e1fc9217b86d6 admin-dashboard diff --git a/bin/admin-dashboard b/bin/admin-dashboard index 31a105d..76a7369 100755 Binary files a/bin/admin-dashboard and b/bin/admin-dashboard differ diff --git a/bin/control-api b/bin/control-api index 55fedc7..5061bbd 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/docs/ADMIN_CLEANUP.md b/docs/ADMIN_CLEANUP.md index 5857d43..c861754 100644 --- a/docs/ADMIN_CLEANUP.md +++ b/docs/ADMIN_CLEANUP.md @@ -12,8 +12,8 @@ | Группа | Таблицы | Можно чистить | |---|---|---| -| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `events` (журнал событий) | да | -| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle` | **нет** | +| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `check_runs` и `run_results` (запуски и итоги адресов в них), `events` (журнал событий) | да | +| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`, `subnets` (список подсетей для аналитики) | **нет** | | Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять | Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`; @@ -96,12 +96,14 @@ UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IS NOT NULL; UPDATE validators SET state = 'idle' WHERE state = 'assigned'; -- порядок важен: сначала зависимые таблицы DELETE FROM ip_site_checks; +DELETE FROM run_results; +DELETE FROM check_runs; DELETE FROM checks; DELETE FROM events; DELETE FROM ip_queue; DELETE FROM ip_registry; -- нумерация снова с 1 (необязательно) -DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events'); +DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events', 'check_runs'); COMMIT; ``` @@ -167,6 +169,7 @@ CREATE TEMP TABLE doomed_ip AS SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg); UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip); DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip); +DELETE FROM run_results WHERE registry_id IN (SELECT id FROM doomed_reg); DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg); DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip); DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip); diff --git a/docs/API.md b/docs/API.md index 7f510dc..7e9b4b4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -705,6 +705,10 @@ 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`. + ### `GET /api/v1/admin/registry/{ip}` Реестровая запись по одному адресу плюс вся сохранённая история проверок @@ -993,3 +997,67 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool Для полностью автоматизированного локального прогона (без ручных curl) см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md). + +## Аналитика запусков + +Запуск — одна «партия» проверок. Он открывается, когда адрес попадает в пустую (или полностью обработанную) +очередь; пока он открыт, в него входят все добавленные и перепроверяемые адреса. Запуск завершается, когда все его +адреса получили итог (`done`, `failed`, `occupied`) либо удалены из очереди. Перепроверка после завершения запуска +открывает **новый** запуск, прежний не меняется. Тип запуска: `auto` (скан автоцикла) или `manual`. Для одного адреса +в запуске хранится результат его последнего цикла. Для данных, накопленных до появления запусков, запуски выделены по +паузам: циклы, которые заканчиваются с промежутком меньше часа, образуют один запуск. + +### `GET /api/v1/admin/analytics/runs` + +Список запусков, новые первыми, для выбора на странице «Аналитика». + +```json +[ + {"id": 2, "kind": "manual", "state": "open", "started_at": "2026-10-03T15:30:00Z", "finalized_at": null, + "addresses": 120, "pass": 40, "partial": 80, "fail": 0, "cancelled": 0, "total": 200, "pending": 80}, + {"id": 1, "kind": "manual", "state": "finalized", "started_at": "2026-10-02T13:46:45Z", "finalized_at": "2026-10-02T22:28:54Z", + "addresses": 6440, "pass": 1962, "partial": 4478, "fail": 0, "cancelled": 0, "total": 6440, "pending": 0} +] +``` + +`addresses` — адреса с итогом, `total` — все адреса запуска в очереди, `pending` — ещё в работе. + +### `GET /api/v1/admin/analytics/runs/{id}` + +Все показатели страницы по одному **завершённому** запуску; открытый запуск — `409`, неизвестный — `404`. +Считаются проверки последнего цикла каждого адреса в запуске, в том числе пришедшие позже вердикта (как факты). +Результат кэшируется, пока данные запуска и список подсетей не менялись. + +| Блок | Содержимое | +|---|---| +| `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»; нулевые не выдаются | +| `quality` | `late_failed_checks_at_pass`, `late_failed_addresses_at_pass`, `ingress_failed_checks`, `ingress_failed_late`, `incomplete_addresses`, `pass_with_failed_addresses`, `pass_by_facts` | +| `subnets` | по подсети: `cidr`, `label`, `addresses`, `pass`, `egress_ok`, `ingress_ok` (без списка подсетей — группы по /24; адрес вне списка — «прочие») | +| `targets` | `types` (семейства egress-проверок), `targets` (хосты, по убыванию провалов https), `failed` (по типу: число адресов с провалом на каждую цель) | +| `matrix` | по типу: строки «подсеть × цель» для подсетей с `partial` (`partial`, `percent` по целям) | +| `sites` | `types` и строки площадок: `total` и `ok` проверок по типу | +| `errors` | классы ошибок проваленных ingress-проверок («SSH: таймаут», «ICMP: нет ответа», …) со счётчиками | +| `validators` | по валидатору: `total`, `ok` https-проверок egress | + +Тип проверки — это `check_type` до первого дефиса: `tcp-22` и `tcp-443` дают `tcp`. + +### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}` + +Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`. +`kind`: `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`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка, +валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»). + +### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets` + +Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком: + +```json +{"subnets": [{"cidr": "83.166.248.0/21", "label": "москва"}, {"cidr": "10.0.0.0/8"}]} +``` + +CIDR приводится к канонической записи (`10.1.2.3/24` → `10.1.2.0/24`), повторы схлопываются; неверный CIDR — `400`, +список остаётся прежним. Адрес относится к самой узкой подходящей подсети. diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 0ccb407..fb55556 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -46,6 +46,11 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml ## Страницы и что на них можно делать +Сайдбар слева: вверху логотип, под ним блок сессии (состояние связи с `control-api` — зелёный индикатор, красный +«нет связи» при сбое, переключатель темы, при включённом входе имя пользователя и кнопка «Выйти»), ниже разделы в двух +группах: «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок, +Настройки). + | Страница | Назначение | |---|---| | `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. | @@ -53,6 +58,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml | `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). | | `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). | | `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. | +| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | @@ -179,7 +185,7 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как относительный путь на этом же сайте). - **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет — - дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (внизу сайдбара) стирает cookie в браузере; + дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (вверху сайдбара) стирает cookie в браузере; скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд. - **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер уходит на страницу входа целиком, а не подставляет её внутрь фрагмента. diff --git a/docs/USAGE.md b/docs/USAGE.md index b9135f7..2c99cc8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -24,6 +24,7 @@ - [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail) - [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу) - [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории) +- [Аналитика запусков](#аналитика-запусков) - [Управление валидаторами](#управление-валидаторами) - [Управление площадками (проберами)](#управление-площадками-проберами) - [Управление типами проверок пробера](#управление-типами-проверок-пробера) @@ -419,6 +420,42 @@ curl -s http://:8080/api/v1/admin/registry/203.0.113.10 | python3 - существует, когда впервые встречен, сколько всего было циклов) не удаляется никогда. +## Аналитика запусков + +Страница `/analytics` в дашборде показывает результаты **одного завершённого запуска проверки**: итоги, причины +`partial`, подсети, провалы по целям, ingress по площадкам, классы ошибок, валидаторы и качество данных. Данные +других запусков на странице не участвуют, поэтому результаты разных прогонов не пересекаются. + +**Что такое запуск.** Запуск открывается, когда адрес попадает в пустую (или полностью обработанную) очередь, а +скан автоцикла помечает его как `авто`. Пока он открыт, в него входят все добавленные и перепроверяемые адреса. +Когда у всех адресов запуска есть итог, запуск завершается и появляется в списке. Перепроверка после этого +открывает **новый** запуск; результаты прежнего остаются как были. Идущий запуск виден в списке, но недоступен +(«идёт, 120 из 800»). Запуски, накопленные до появления этой функции, выделены по паузам больше часа. + +**Как читать числа.** Показатели считаются по фактическим проверкам последнего цикла каждого адреса, включая +пришедшие позже вердикта; вердикт системы показан рядом. `Egress OK` и `Ingress OK` — доли адресов, у которых все +записанные проверки уровня успешны. Блок «Качество данных» показывает, насколько вердикт расходится с проверками +(поздние результаты, неполный набор). После изменения «вердикт без опоздавших результатов» поздних результатов в новых +запусках быть не должно. + +**Что можно открыть.** Карточки «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все +провалены», а также каждая строка блока «Классы ошибок ingress» открывают окно со списком адресов (для класса ошибок +— с распределением по валидаторам и статусом каждой проверки). В окне кнопки «Скачать CSV» (файл от control-api, +UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр» +с фильтром по запуску и подсети (`/registry?run=…&subnet=…`). + +**Подсети.** Список задаётся на `/settings` (блок «Подсети»): по одной в строке, CIDR и, через пробел, подпись. Адрес +относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса +группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`. + +```bash +curl -s http://:8080/api/v1/admin/analytics/runs | python3 -m json.tool +curl -s http://:8080/api/v1/admin/analytics/runs/1 | python3 -m json.tool +curl -s -o egress.csv "http://:8080/api/v1/admin/analytics/runs/1/lists/egress_https_all?format=csv" +``` + +Подробности и состав ответов — в [API.md](API.md#аналитика-запусков). + ## Управление валидаторами Список валидаторов и их текущее состояние: diff --git a/docs/changes/2026-10-03_16-39_analytics-section-plan.md b/docs/changes/2026-10-03_16-39_analytics-section-plan.md index 42c0a65..e08f50f 100644 --- a/docs/changes/2026-10-03_16-39_analytics-section-plan.md +++ b/docs/changes/2026-10-03_16-39_analytics-section-plan.md @@ -139,3 +139,11 @@ ## 10. Порядок по правилам проекта План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа. + +## 11. Уточнения при реализации (макет утверждён) + +- Реализуется страница, точно повторяющая макет `docs/mockups/analytics-mockup.html`; отличия только там, где макет был заглушкой (данные, ссылки) или где этого требует рабочее приложение: время в подписях запусков — локальное время дашборда (как везде в нём), матрица «подсеть × цель» строится для любого типа проверки (в макете для `icmp` её не было), надпись про перепроверки показывается, только если они были. +- Решения по открытым вопросам плана приняты по умолчанию: перепроверка после завершения запуска открывает новый запуск; список подсетей хранится в БД (`subnets`) и задаётся на `/settings`, пока пуст — группы по /24. +- Миграция `0011` (запуски, `run_results`, `subnets`, `run_id` у `ip_queue` и `checks`, заполнение накопленных данных, валидатор у ingress-проверок). Раньше плана «Аналитика» выполнена миграция `0010` (вердикт без опоздавших результатов). +- Ссылки из подсетей ведут в «Реестр» с фильтрами `run` и `subnet` (добавлены в `GET /admin/registry` и `/registry`). +- **Сайдбар** (новое требование): убраны три точки возле логотипа; индикатор связи с control-api, переключатель темы и кнопка выхода подняты наверх в блок сессии под логотипом; индикатор краснеет («нет связи»), когда control-api недоступен; разделы разбиты на группы «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок, Настройки); нижний блок сайдбара убран. diff --git a/docs/changes/2026-10-03_18-41_analytics-section-summary.md b/docs/changes/2026-10-03_18-41_analytics-section-summary.md new file mode 100644 index 0000000..2b158e7 --- /dev/null +++ b/docs/changes/2026-10-03_18-41_analytics-section-summary.md @@ -0,0 +1,38 @@ +# Раздел «Аналитика» — итог + +План: [2026-10-03_16-39_analytics-section-plan.md](2026-10-03_16-39_analytics-section-plan.md). Макет: [docs/mockups/analytics-mockup.html](../mockups/analytics-mockup.html). + +Статус: код, тесты и документация готовы. На стенд не выложено (бинарники `bin/` не пересобирались), не закоммичено. + +## Что сделано + +**Запуски (миграция `0011`).** Новые таблицы `check_runs`, `run_results`, `subnets`; колонки `run_id` у `ip_queue` и `checks`. Запуск открывается, когда адрес попадает в пустую или полностью обработанную очередь (`SubmitIPsAs`, `SeedQueue`), принимает всё добавленное и перепроверенное, пока открыт, и завершается, когда у всех его адресов есть итог или они удалены (`finalizeRunsTx` в `FinishIPExpected`, `CancelIP`, `RequeueOrFail`, `MarkFIPOccupied`, удалении и очистке; плюс страховка в такте оркестратора). Скан автоцикла помечает запуск `auto`. Итог адреса (`run_results`) пишется при вердикте вместе с ожидаемым и записанным числом проверок. Ingress-проверка получает `validator_id` держателя адреса при записи. Накопленные данные размечены миграцией: запуски выделяются паузами больше часа, итоги берутся из очереди или считаются по проверкам (помечаются `verdict_derived`), валидатор ingress восстановлен из события `fip_associated`; живые строки очереди без запуска попадают в открытый запуск при открытии БД. + +**Расчёт (`internal/analytics`).** Показатели считаются по фактам: все проверки последнего цикла каждого адреса запуска, в том числе пришедшие позже вердикта. Блоки: показатели, причины `partial`, качество данных, подсети, цели и матрица «подсеть × цель» по типам проверок, площадки, классы ошибок, валидаторы; списки адресов (4 индикатора и класс ошибки). Подсеть — самая узкая подходящая; без списка — /24. + +**API.** `GET /admin/analytics/runs`, `/runs/{id}` (кэш по версии данных запуска и списку подсетей; открытый запуск — 409), `/runs/{id}/lists/{kind}` (JSON и `?format=csv`), `GET`/`PUT /admin/config/subnets`; фильтры `run` и `subnet` в `GET /admin/registry`. + +**Страница `/analytics`.** Точно по макету: выбор запуска (идущий виден, недоступен), 12 карточек, причины `partial`, качество данных, подсети, egress по целям с вкладками по типу и тепловой картой, ingress по площадкам, классы ошибок, валидаторы; окна со списками адресов, подсказками и выгрузкой CSV (скачивание — с сервера, `Content-Disposition: attachment`). Стили `static/analytics.css` (классы с префиксом `an-`, не пересекаются со стилями дашборда), скрипт `static/analytics.js`. В `/settings` блок «Подсети». Из подсетей и матрицы — переход в «Реестр» с фильтром (`/registry?run=…&subnet=…`, плашка фильтра со сбросом). + +**Сайдбар.** Убраны три точки у логотипа. Индикатор связи с control-api (краснеет «нет связи» при сбое), переключатель темы и кнопка «Выйти» подняты наверх, в блок сессии под логотипом. Разделы разбиты на «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка». Нижний блок убран. + +## Проверка + +- `go build ./... && go vet ./... && go test ./...` проходят. Новые тесты: БД (жизненный цикл запуска, присоединение и новый запуск при перепроверке, очистка и удаление, отмена и провал по повторам, валидатор ingress, подсети, фильтры реестра, миграция `0011` на базе версии 10), `internal/analytics` (расчёт по синтетическим данным, подсети, классы ошибок, списки), API (отчёт, списки, CSV, кэш, подсети, фильтры, 409/404/400), дашборд (страница одного запуска и пустое состояние, прокси списков и CSV, сайдбар, индикатор связи, форма подсетей, drill-down в реестр). +- **Контрольные числа** на копии боевой БД (запуск 02.10, 6440 адресов) после миграции `0011` совпали с отчётами `analysis/`: 1962 `pass` / 4478 `partial`; Egress OK 2003, Ingress OK 6215; причины `partial` 4146 / 157 / 125 / 9 / 33 / 8; https: есть провалы 4409, все провалены 307 (по всем 5 целям 290); ssh: есть провалы 222, все провалены 7; провалы по целям 3841 / 2193 / 1872 / 1805 / 1728; первая строка матрицы 83.166.248.0/21: 81 / 57 / 51 / 86 / 46; классы ошибок 327 / 276 / 260 / 47 / 18 / 15 / 7; поздние провалы 246 у 50 адресов, 844 из 950 ingress-провалов после вердикта, неполный набор 167, `pass` по фактам 1912. Миграция на копии — около 8 с, расчёт отчёта — около 2,5 с. +- **В браузере** (headless Chrome, локальный control-api и дашборд на той же копии): страница на 1440 px и 390 px, без горизонтальной прокрутки на телефоне; окна списков (4 409 строк) и класса ошибок открываются, кнопки внутри окна видны, вкладки типов и матрица работают, CSV отдаётся файлом. + +## Отличия от макета + +- Время в подписях запусков — локальное время дашборда (в макете было UTC). +- Матрица «подсеть × цель» строится и для `icmp` (в макете для него её не было). +- Строка «Перепроверено внутри запуска» показывается, только если такие адреса есть (в БД стенда прежние циклы этих адресов уже удалены, поэтому 0). +- Карточки не переносят значение на вторую строку: минимальная ширина карточки 168 px, на телефоне шрифт значения меньше. +- Таблицы данных на телефоне прокручиваются вбок, а не превращаются в карточки, как остальные таблицы дашборда. + +## Что не сделано и ограничения + +- Выкладка на стенд: нужна пересборка `control-api` и `admin-dashboard` и миграция `0011` на боевой БД (копия БД перед ней обязательна; процедура — в памяти проекта и в `docs/SETUP.md`). После выкладки задать список подсетей клиента на `/settings` (45 подсетей из отчёта) — без него адреса группируются по /24. +- Подсказки при наведении не работают на сенсорных экранах и с клавиатуры (как и в макете). +- В сайдбаре на узком экране (меню-«бургер») новый блок сессии я не просматривал отдельно. +- Фильтр реестра по подсети ограничен адресами, попавшими в список подсетей: строка «прочие» без ссылки. diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go new file mode 100644 index 0000000..3938114 --- /dev/null +++ b/internal/analytics/analytics.go @@ -0,0 +1,664 @@ +// Package analytics turns the stored checks of one finished run into the +// numbers behind the dashboard's analytics page. It works on facts: every +// check stored for the latest cycle of each address in the run, whenever it +// arrived. The verdict is shown next to those facts, never mixed into them. +package analytics + +import ( + "net/netip" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "cloudipvalidator/internal/db" +) + +// Input is everything Compute needs, already read from the database. +type Input struct { + Run db.CheckRun + Results []db.RunResult + Subnets []db.Subnet + SiteNames map[int]string // site index -> site id + Rechecked int // addresses with more than one cycle in the run + // Each feeds every check of the run's result cycles to fn. + Each func(fn func(db.RunCheck)) error +} + +// Report is the data of the analytics page for one run. +type Report struct { + Run RunInfo `json:"run"` + Summary Summary `json:"summary"` + Reasons []Reason `json:"reasons"` + Quality Quality `json:"quality"` + Subnets []SubnetRow `json:"subnets"` + Targets TargetsBlock `json:"targets"` + Matrix map[string][]MatrixRow `json:"matrix"` + Sites SitesBlock `json:"sites"` + Errors []ErrorClass `json:"errors"` + Validators []ValidatorRow `json:"validators"` +} + +type RunInfo struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + FinalizedAt *time.Time `json:"finalized_at"` + DurationSec int `json:"duration_seconds"` + Rechecked int `json:"rechecked"` +} + +type Summary struct { + Addresses int `json:"addresses"` + Pass int `json:"pass"` + Partial int `json:"partial"` + Fail int `json:"fail"` + Cancelled int `json:"cancelled"` + EgressOK int `json:"egress_ok"` + IngressOK int `json:"ingress_ok"` + EgressHTTPSAny int `json:"egress_https_any_failed"` + EgressHTTPSAll int `json:"egress_https_all_failed"` + // EgressHTTPSAllTargets counts the addresses that failed https to every + // target of the full set (as many checks as the best-covered address). + EgressHTTPSAllTargets int `json:"egress_https_all_targets_failed"` + IngressSSHAny int `json:"ingress_ssh_any_failed"` + IngressSSHAll int `json:"ingress_ssh_all_failed"` + PerMinute float64 `json:"addresses_per_minute"` +} + +type Reason struct { + Name string `json:"name"` + Count int `json:"count"` +} + +// Quality is the data-quality block: how the verdict relates to the checks. +type Quality struct { + LateFailedAtPass int `json:"late_failed_checks_at_pass"` + LateFailedAtPassAddresses int `json:"late_failed_addresses_at_pass"` + IngressFailed int `json:"ingress_failed_checks"` + IngressFailedLate int `json:"ingress_failed_late"` + Incomplete int `json:"incomplete_addresses"` + PassWithFailed int `json:"pass_with_failed_addresses"` + PassByFacts int `json:"pass_by_facts"` +} + +type SubnetRow struct { + CIDR string `json:"cidr"` + Label string `json:"label,omitempty"` + Addresses int `json:"addresses"` + Pass int `json:"pass"` + EgressOK int `json:"egress_ok"` + IngressOK int `json:"ingress_ok"` +} + +type TargetsBlock struct { + Types []string `json:"types"` + Targets []string `json:"targets"` + Failed map[string][]int `json:"failed"` // type -> failed addresses per target, in Targets order +} + +type MatrixRow struct { + CIDR string `json:"cidr"` + Partial int `json:"partial"` + Percent []int `json:"percent"` // per target, in Targets order +} + +type SitesBlock struct { + Types []string `json:"types"` + Rows []SiteRow `json:"rows"` +} + +type SiteRow struct { + Site string `json:"site"` + Stats []SiteStat `json:"stats"` // per type, in Types order +} + +type SiteStat struct { + Total int `json:"total"` + OK int `json:"ok"` +} + +type ErrorClass struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type ValidatorRow struct { + Validator string `json:"validator"` + Total int `json:"total"` + OK int `json:"ok"` +} + +type typeStat struct{ n, ok int } + +type failedIngress struct { + class, site, validator string + late bool +} + +// addr is everything known about one address of the run. +type addr struct { + res db.RunResult + subnet string + egress typeStat + ingress typeStat + stored int + https struct { + typeStat + validator string + failedTargets []string + } + ssh struct { + typeStat + sites []string + errs map[string]bool + } + failedTargets map[string]bool // family\x00target -> failed + failedIngress []failedIngress + lateFailed int +} + +// Analysis is a computed report plus the per-address data the lists are cut from. +type Analysis struct { + Report Report + addrs []*addr + siteNames map[int]string +} + +// Compute reads the checks of the run once and builds the report. +func Compute(in Input) (*Analysis, error) { + byReg := make(map[int64]*addr, len(in.Results)) + var addrs []*addr + subnetOf := newSubnetMatcher(in.Subnets) + for _, r := range in.Results { + a := &addr{res: r, subnet: subnetOf(r.IPAddress), failedTargets: map[string]bool{}} + a.ssh.errs = map[string]bool{} + byReg[r.RegistryID] = a + addrs = append(addrs, a) + } + siteName := func(source string) string { + idx, _ := strconv.Atoi(strings.TrimPrefix(source, "inbound-site-")) + if n := in.SiteNames[idx]; n != "" { + return n + } + return "site-" + strconv.Itoa(idx) + } + + type key struct{ site, typ string } + siteStats := map[key]*typeStat{} + siteTypes := map[string]bool{} + egressTypes := map[string]bool{} + valHTTPS := map[string]*typeStat{} + targetSet := map[string]bool{} + errCount := map[string]int{} + + err := in.Each(func(c db.RunCheck) { + a := byReg[c.RegistryID] + if a == nil || a.res.Verdict == db.ResultCancelled { + return // a cancelled address was stopped, its checks say nothing + } + a.stored++ + late := c.AfterVerdict || c.RecordedAt.After(a.res.AggregatedAt) + family := db.CheckFamily(c.CheckType) + switch db.CheckLevel(c.Source) { + case db.LevelEgress: + a.egress.n++ + if c.Success { + a.egress.ok++ + } + egressTypes[family] = true + target := normalizeTarget(c.Target) + targetSet[target] = true + if !c.Success { + a.failedTargets[family+"\x00"+target] = true + } + if family == "https" { + a.https.n++ + a.https.validator = c.ValidatorID + if c.Success { + a.https.ok++ + } else { + a.https.failedTargets = append(a.https.failedTargets, target) + } + v := valHTTPS[c.ValidatorID] + if v == nil { + v = &typeStat{} + valHTTPS[c.ValidatorID] = v + } + v.n++ + if c.Success { + v.ok++ + } + } + case db.LevelIngress: + a.ingress.n++ + if c.Success { + a.ingress.ok++ + } + site := siteName(c.Source) + siteTypes[family] = true + ss := siteStats[key{site, family}] + if ss == nil { + ss = &typeStat{} + siteStats[key{site, family}] = ss + } + ss.n++ + if c.Success { + ss.ok++ + } + if family == "ssh" { + a.ssh.n++ + if c.Success { + a.ssh.ok++ + } + } + if !c.Success { + class := ErrorClassOf(c.CheckType, c.Detail) + errCount[class]++ + a.failedIngress = append(a.failedIngress, failedIngress{class: class, site: site, validator: c.ValidatorID, late: late}) + if family == "ssh" { + a.ssh.sites = append(a.ssh.sites, site) + a.ssh.errs[errorReason(c.CheckType, c.Detail)] = true + } + } + } + if late && !c.Success { + a.lateFailed++ + } + }) + if err != nil { + return nil, err + } + + rep := Report{Matrix: map[string][]MatrixRow{}} + rep.Run = RunInfo{ID: in.Run.ID, Kind: in.Run.Kind, State: in.Run.State, StartedAt: in.Run.StartedAt, + FinalizedAt: in.Run.FinalizedAt, Rechecked: in.Rechecked} + if in.Run.FinalizedAt != nil { + rep.Run.DurationSec = int(in.Run.FinalizedAt.Sub(in.Run.StartedAt).Seconds()) + } + + maxHTTPS := 0 + for _, a := range addrs { + if a.https.n > maxHTTPS { + maxHTTPS = a.https.n + } + } + + reasonCount := map[string]int{} + type subAgg struct { + n, pass, eg, ing, partial int + failed map[string]int + } + subs := map[string]*subAgg{} + sum := &rep.Summary + for _, a := range addrs { + v := a.res.Verdict + if v == db.ResultCancelled { + sum.Cancelled++ + continue + } + sum.Addresses++ + switch v { + case db.ResultPass: + sum.Pass++ + case db.ResultPartial: + sum.Partial++ + case db.ResultFail: + sum.Fail++ + } + egOK := a.egress.n > 0 && a.egress.ok == a.egress.n + inOK := a.ingress.n > 0 && a.ingress.ok == a.ingress.n + if egOK { + sum.EgressOK++ + } + if inOK { + sum.IngressOK++ + } + if a.https.n > 0 && a.https.ok < a.https.n { + sum.EgressHTTPSAny++ + if a.https.ok == 0 { + sum.EgressHTTPSAll++ + if a.https.n == maxHTTPS { + sum.EgressHTTPSAllTargets++ + } + } + } + if a.ssh.n > 0 && a.ssh.ok < a.ssh.n { + sum.IngressSSHAny++ + if a.ssh.ok == 0 { + sum.IngressSSHAll++ + } + } + + egFail := a.egress.ok < a.egress.n + inFail := a.ingress.ok < a.ingress.n + incomplete := a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks + if incomplete { + rep.Quality.Incomplete++ + } + if v == db.ResultPartial { + reasonCount[reasonName(egFail, inFail, incomplete)]++ + } + if v == db.ResultPass { + if a.egress.ok < a.egress.n || a.ingress.ok < a.ingress.n { + rep.Quality.PassWithFailed++ + rep.Quality.LateFailedAtPass += a.lateFailed + rep.Quality.LateFailedAtPassAddresses++ + } + } + + sa := subs[a.subnet] + if sa == nil { + sa = &subAgg{failed: map[string]int{}} + subs[a.subnet] = sa + } + sa.n++ + if v == db.ResultPass { + sa.pass++ + } + if egOK { + sa.eg++ + } + if inOK { + sa.ing++ + } + if v == db.ResultPartial { + sa.partial++ + for k := range a.failedTargets { + sa.failed[k]++ + } + } + } + rep.Quality.PassByFacts = sum.Pass - rep.Quality.PassWithFailed + if sum.Addresses > 0 && rep.Run.DurationSec > 0 { + sum.PerMinute = float64(sum.Addresses) / (float64(rep.Run.DurationSec) / 60) + } + for _, a := range addrs { + for _, f := range a.failedIngress { + rep.Quality.IngressFailed++ + if f.late { + rep.Quality.IngressFailedLate++ + } + } + } + + for _, name := range reasonOrder { + if n := reasonCount[name]; n > 0 { + rep.Reasons = append(rep.Reasons, Reason{Name: name, Count: n}) + } + } + + // Subnets: worst first is the page's job; the report lists them by size. + labels := map[string]string{} + for _, s := range in.Subnets { + labels[s.CIDR] = s.Label + } + for cidr, sa := range subs { + rep.Subnets = append(rep.Subnets, SubnetRow{CIDR: cidr, Label: labels[cidr], Addresses: sa.n, Pass: sa.pass, EgressOK: sa.eg, IngressOK: sa.ing}) + } + sort.Slice(rep.Subnets, func(i, j int) bool { + if rep.Subnets[i].Addresses != rep.Subnets[j].Addresses { + return rep.Subnets[i].Addresses > rep.Subnets[j].Addresses + } + return rep.Subnets[i].CIDR < rep.Subnets[j].CIDR + }) + + // Targets and the subnet x target matrix, per egress check family. + types := sortedKeys(egressTypes) + rep.Targets.Types = types + failedAddrs := map[string]int{} // family\x00target -> addresses + for _, a := range addrs { + if a.res.Verdict == db.ResultCancelled { + continue + } + for k := range a.failedTargets { + failedAddrs[k]++ + } + } + targets := sortedKeys(targetSet) + lead := "" + if len(types) > 0 { + lead = types[0] + for _, t := range types { + if t == "https" { + lead = t + } + } + } + sort.SliceStable(targets, func(i, j int) bool { + fi, fj := failedAddrs[lead+"\x00"+targets[i]], failedAddrs[lead+"\x00"+targets[j]] + if fi != fj { + return fi > fj + } + return targets[i] < targets[j] + }) + rep.Targets.Targets = targets + rep.Targets.Failed = map[string][]int{} + for _, t := range types { + row := make([]int, len(targets)) + for i, tg := range targets { + row[i] = failedAddrs[t+"\x00"+tg] + } + rep.Targets.Failed[t] = row + } + for _, t := range types { + var rows []MatrixRow + for cidr, sa := range subs { + if sa.partial == 0 { + continue + } + pc := make([]int, len(targets)) + for i, tg := range targets { + pc[i] = int(float64(sa.failed[t+"\x00"+tg])/float64(sa.partial)*100 + 0.5) + } + rows = append(rows, MatrixRow{CIDR: cidr, Partial: sa.partial, Percent: pc}) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Partial != rows[j].Partial { + return rows[i].Partial > rows[j].Partial + } + return rows[i].CIDR < rows[j].CIDR + }) + rep.Matrix[t] = rows + } + + // Sites. + rep.Sites.Types = sortedKeys(siteTypes) + names := map[string]bool{} + for k := range siteStats { + names[k.site] = true + } + siteList := sortedKeys(names) + sort.Slice(siteList, func(i, j int) bool { + return siteIndexOf(in.SiteNames, siteList[i]) < siteIndexOf(in.SiteNames, siteList[j]) + }) + for _, s := range siteList { + row := SiteRow{Site: s} + for _, t := range rep.Sites.Types { + st := siteStats[key{s, t}] + if st == nil { + st = &typeStat{} + } + row.Stats = append(row.Stats, SiteStat{Total: st.n, OK: st.ok}) + } + rep.Sites.Rows = append(rep.Sites.Rows, row) + } + + for name, n := range errCount { + rep.Errors = append(rep.Errors, ErrorClass{Name: name, Count: n}) + } + sort.Slice(rep.Errors, func(i, j int) bool { + if rep.Errors[i].Count != rep.Errors[j].Count { + return rep.Errors[i].Count > rep.Errors[j].Count + } + return rep.Errors[i].Name < rep.Errors[j].Name + }) + + for id, st := range valHTTPS { + rep.Validators = append(rep.Validators, ValidatorRow{Validator: id, Total: st.n, OK: st.ok}) + } + sort.Slice(rep.Validators, func(i, j int) bool { + a, b := validatorNumber(rep.Validators[i].Validator), validatorNumber(rep.Validators[j].Validator) + if a != b { + return a < b + } + return rep.Validators[i].Validator < rep.Validators[j].Validator + }) + + return &Analysis{Report: rep, addrs: addrs, siteNames: in.SiteNames}, nil +} + +var reasonOrder = []string{ + "Только egress", + "Ingress и egress", + "Egress и неполный набор", + "Ingress, egress и неполный набор", + "Только неполный набор", + "Только ingress", + "Ingress и неполный набор", + "Прочее", +} + +func reasonName(egress, ingress, incomplete bool) string { + switch { + case egress && ingress && incomplete: + return "Ingress, egress и неполный набор" + case egress && ingress: + return "Ingress и egress" + case egress && incomplete: + return "Egress и неполный набор" + case egress: + return "Только egress" + case ingress && incomplete: + return "Ingress и неполный набор" + case ingress: + return "Только ingress" + case incomplete: + return "Только неполный набор" + } + return "Прочее" +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func siteIndexOf(names map[int]string, site string) int { + for i, n := range names { + if n == site { + return i + } + } + if n, err := strconv.Atoi(strings.TrimPrefix(site, "site-")); err == nil { + return n + } + return 1 << 20 +} + +// validatorNumber is the trailing number of a validator id ("vkiplab-v12" -> +// 12), or 1<<20 when there is none, so numbered validators sort naturally. +func validatorNumber(id string) int { + i := len(id) + for i > 0 && id[i-1] >= '0' && id[i-1] <= '9' { + i-- + } + if i == len(id) { + return 1 << 20 + } + n, _ := strconv.Atoi(id[i:]) + return n +} + +// ShortValidator is the validator id as the page shows it: "vkiplab-v12" -> +// "v12"; ids without a number stay whole. +func ShortValidator(id string) string { + n := validatorNumber(id) + if n == 1<<20 { + return id + } + return "v" + strconv.Itoa(n) +} + +// normalizeTarget is the host of an egress target: https://host/path -> host. +func normalizeTarget(t string) string { + if u, err := url.Parse(t); err == nil && u.Host != "" { + return u.Hostname() + } + return strings.TrimSuffix(t, "/") +} + +// newSubnetMatcher returns a function that maps an address to the most +// specific configured subnet. With no subnets configured addresses group by +// /24 (/64 for IPv6). An address outside the list goes to "прочие". +func newSubnetMatcher(subnets []db.Subnet) func(string) string { + type entry struct { + p netip.Prefix + name string + } + var list []entry + for _, s := range subnets { + if p, err := netip.ParsePrefix(s.CIDR); err == nil { + list = append(list, entry{p.Masked(), p.Masked().String()}) + } + } + sort.Slice(list, func(i, j int) bool { return list[i].p.Bits() > list[j].p.Bits() }) + return func(ip string) string { + a, err := netip.ParseAddr(ip) + if err != nil { + return "прочие" + } + if len(list) == 0 { + bits := 24 + if a.Is6() { + bits = 64 + } + p, _ := a.Prefix(bits) + return p.String() + } + for _, e := range list { + if e.p.Contains(a) { + return e.name + } + } + return "прочие" + } +} + +// ErrorClassOf names the class of a failed ingress check: the check type and +// the reason, e.g. "SSH: таймаут", "ICMP: нет ответа". +func ErrorClassOf(checkType, detail string) string { + return strings.ToUpper(checkType) + ": " + errorReason(checkType, detail) +} + +func errorReason(checkType, detail string) string { + d := strings.ToLower(detail) + switch { + case strings.Contains(d, "unexpected banner prefix"): + if strings.Contains(d, "not allo") { + return "баннер «Not allowed»" + } + return "неожиданный баннер" + case strings.Contains(d, "no route to host"): + return "нет маршрута" + case strings.Contains(d, "time exceeded"): + return "time exceeded" + case strings.Contains(d, "connection refused"): + return "отказ в соединении" + case strings.Contains(d, "timeout") || strings.Contains(d, "deadline exceeded"): + if strings.EqualFold(checkType, "icmp") { + return "нет ответа" + } + return "таймаут" + } + if strings.EqualFold(checkType, "icmp") { + return "нет ответа" + } + return "прочее" +} diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go new file mode 100644 index 0000000..20c4d15 --- /dev/null +++ b/internal/analytics/analytics_test.go @@ -0,0 +1,229 @@ +package analytics + +import ( + "reflect" + "testing" + "time" + + "cloudipvalidator/internal/db" +) + +var t0 = time.Date(2026, 10, 2, 13, 0, 0, 0, time.UTC) + +type fixture struct { + results []db.RunResult + checks []db.RunCheck +} + +func (f *fixture) addr(reg int64, ip, verdict string, expected int) { + f.results = append(f.results, db.RunResult{RegistryID: reg, IPAddress: ip, CycleID: 1, Verdict: verdict, + AggregatedAt: t0.Add(time.Minute), ExpectedChecks: expected}) +} + +func (f *fixture) check(reg int64, source, typ, target string, ok bool, validator, detail string, late bool) { + rec := t0 + if late { + rec = t0.Add(time.Hour) + } + f.checks = append(f.checks, db.RunCheck{RegistryID: reg, Source: source, CheckType: typ, Target: target, Success: ok, + ValidatorID: validator, Detail: detail, RecordedAt: rec}) +} + +func (f *fixture) compute(t *testing.T, subnets []db.Subnet) *Analysis { + t.Helper() + end := t0.Add(10 * time.Minute) + an, err := Compute(Input{ + Run: db.CheckRun{ID: 7, Kind: db.RunManual, State: db.RunFinalized, StartedAt: t0, FinalizedAt: &end}, + Results: f.results, + Subnets: subnets, + SiteNames: map[int]string{1: "rxmsk", 2: "rxyc"}, + Each: func(fn func(db.RunCheck)) error { + for _, c := range f.checks { + fn(c) + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + return an +} + +const ( + eg = db.SourceEgress + s1 = "inbound-site-1" + s2 = "inbound-site-2" +) + +func TestComputeCountsFactsPerAddress(t *testing.T) { + f := &fixture{} + // 1: all fine + f.addr(1, "10.0.0.1", db.ResultPass, 6) + // 2: egress https fails on both targets, rest fine + f.addr(2, "10.0.0.2", db.ResultPartial, 6) + // 3: ingress ssh fails on one site, set incomplete (5 of 6 stored) + f.addr(3, "10.0.1.1", db.ResultPartial, 6) + // 4: pass at the verdict, but a failed ingress check arrived afterwards + f.addr(4, "10.0.1.2", db.ResultPass, 6) + // 5: cancelled, not counted + f.addr(5, "10.0.1.3", db.ResultCancelled, 6) + + good := func(reg int64) { + f.check(reg, eg, "https", "https://a.test/x", true, "vkiplab-v1", "", false) + f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false) + f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false) + f.check(reg, s1, "ssh", "ip", true, "vkiplab-v1", "", false) + f.check(reg, s2, "icmp", "ip", true, "vkiplab-v1", "", false) + f.check(reg, s2, "ssh", "ip", true, "vkiplab-v1", "", false) + } + good(1) + f.check(2, eg, "https", "https://a.test/x", false, "vkiplab-v2", `Get "https://a.test/x": context deadline exceeded`, false) + f.check(2, eg, "https", "https://b.test", false, "vkiplab-v2", "", false) + for _, s := range []string{s1, s2} { + f.check(2, s, "icmp", "ip", true, "vkiplab-v2", "", false) + f.check(2, s, "ssh", "ip", true, "vkiplab-v2", "", false) + } + f.check(3, eg, "https", "https://a.test/x", true, "vkiplab-v3", "", false) + f.check(3, eg, "https", "https://b.test", true, "vkiplab-v3", "", false) + f.check(3, s1, "icmp", "ip", true, "vkiplab-v3", "", false) + f.check(3, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp 1.2.3.4:22: i/o timeout", false) + f.check(3, s2, "icmp", "ip", true, "vkiplab-v3", "", false) // the 6th check is missing + // 4: the failed ssh arrived after the verdict + f.check(4, eg, "https", "https://a.test/x", true, "vkiplab-v4", "", false) + f.check(4, eg, "https", "https://b.test", true, "vkiplab-v4", "", false) + f.check(4, s1, "icmp", "ip", true, "vkiplab-v4", "", false) + f.check(4, s1, "ssh", "ip", false, "vkiplab-v4", `unexpected banner prefix "Not allo"`, true) + f.check(4, s2, "icmp", "ip", true, "vkiplab-v4", "", false) + f.check(4, s2, "ssh", "ip", true, "vkiplab-v4", "", false) + good(5) + + an := f.compute(t, []db.Subnet{{CIDR: "10.0.0.0/24"}, {CIDR: "10.0.1.0/24"}}) + r := an.Report + + want := Summary{Addresses: 4, Pass: 2, Partial: 2, Cancelled: 1, EgressOK: 3, IngressOK: 2, + EgressHTTPSAny: 1, EgressHTTPSAll: 1, EgressHTTPSAllTargets: 1, IngressSSHAny: 2, IngressSSHAll: 1} + got := r.Summary + got.PerMinute = 0 + if got != want { + t.Errorf("summary = %+v\nwant %+v", got, want) + } + if r.Run.DurationSec != 600 || r.Run.ID != 7 { + t.Errorf("run info: %+v", r.Run) + } + + if wantReasons := []Reason{{"Только egress", 1}, {"Ingress и неполный набор", 1}}; !reflect.DeepEqual(r.Reasons, wantReasons) { + t.Errorf("reasons = %+v, want %+v", r.Reasons, wantReasons) + } + + q := r.Quality + if q.Incomplete != 1 || q.PassWithFailed != 1 || q.PassByFacts != 1 || q.LateFailedAtPass != 1 || q.LateFailedAtPassAddresses != 1 || + q.IngressFailed != 2 || q.IngressFailedLate != 1 { + t.Errorf("quality = %+v", q) + } + + // Errors are classed by check type and reason. + wantErrs := []ErrorClass{{"SSH: баннер «Not allowed»", 1}, {"SSH: таймаут", 1}} + if !reflect.DeepEqual(r.Errors, wantErrs) { + t.Errorf("errors = %+v", r.Errors) + } + + // Targets: hosts, https is the lead type; address 2 failed both. + if !reflect.DeepEqual(r.Targets.Targets, []string{"a.test", "b.test"}) || !reflect.DeepEqual(r.Targets.Failed["https"], []int{1, 1}) { + t.Errorf("targets = %+v", r.Targets) + } + if len(r.Subnets) != 2 || r.Subnets[0].Addresses != 2 { + t.Errorf("subnets = %+v", r.Subnets) + } + if rows := r.Matrix["https"]; len(rows) != 2 || rows[0].CIDR != "10.0.0.0/24" && rows[0].CIDR != "10.0.1.0/24" { + t.Errorf("matrix = %+v", r.Matrix) + } + + // Sites in index order, types sorted. + if !reflect.DeepEqual(r.Sites.Types, []string{"icmp", "ssh"}) || len(r.Sites.Rows) != 2 || r.Sites.Rows[0].Site != "rxmsk" { + t.Errorf("sites = %+v", r.Sites) + } + // ssh at rxmsk: addresses 1, 2, 3, 4 (the cancelled one is not counted) -> 4 checks, 2 failed. + if st := r.Sites.Rows[0].Stats[1]; st.Total != 4 || st.OK != 2 { + t.Errorf("rxmsk ssh = %+v", st) + } + // Validators by number. + if len(r.Validators) != 4 || r.Validators[0].Validator != "vkiplab-v1" || r.Validators[0].Total != 2 { + t.Errorf("validators = %+v", r.Validators) + } +} + +func TestSubnetMatching(t *testing.T) { + in := []db.Subnet{{CIDR: "10.0.0.0/8"}, {CIDR: "10.1.0.0/16"}} + m := newSubnetMatcher(in) + for ip, want := range map[string]string{"10.1.2.3": "10.1.0.0/16", "10.2.0.1": "10.0.0.0/8", "192.0.2.1": "прочие", "garbage": "прочие"} { + if got := m(ip); got != want { + t.Errorf("%s -> %s, want %s", ip, got, want) + } + } + auto := newSubnetMatcher(nil) + if got := auto("203.0.113.77"); got != "203.0.113.0/24" { + t.Errorf("without a list addresses group by /24, got %s", got) + } +} + +func TestErrorClassOf(t *testing.T) { + for _, c := range []struct{ typ, detail, want string }{ + {"ssh", `read banner: read tcp 1.2.3.4:5->6.7.8.9:22: i/o timeout`, "SSH: таймаут"}, + {"ssh", `unexpected banner prefix "Not allo"`, "SSH: баннер «Not allowed»"}, + {"ssh", `dial tcp 1.2.3.4:22: connect: no route to host`, "SSH: нет маршрута"}, + {"tcp-22", `dial tcp 1.2.3.4:22: i/o timeout`, "TCP-22: таймаут"}, + {"tcp-22", `connect: connection refused`, "TCP-22: отказ в соединении"}, + {"icmp", `read echo reply: read ip4 0.0.0.0: i/o timeout`, "ICMP: нет ответа"}, + {"icmp", `unexpected icmp type time exceeded`, "ICMP: time exceeded"}, + {"ssh", `something new`, "SSH: прочее"}, + } { + if got := ErrorClassOf(c.typ, c.detail); got != c.want { + t.Errorf("%s %q = %q, want %q", c.typ, c.detail, got, c.want) + } + } +} + +func TestListsAndShortValidator(t *testing.T) { + f := &fixture{} + f.addr(1, "10.0.0.9", db.ResultPartial, 4) + f.addr(2, "10.0.0.10", db.ResultPass, 4) + f.check(1, eg, "https", "https://a.test", false, "vkiplab-v12", "", false) + f.check(1, eg, "https", "https://b.test", false, "vkiplab-v12", "", false) + f.check(1, s2, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", false) + f.check(1, s1, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", true) + f.check(2, eg, "https", "https://a.test", true, "vkiplab-v3", "", false) + f.check(2, eg, "https", "https://b.test", false, "vkiplab-v3", "", false) + an := f.compute(t, nil) + + l, err := an.List(ListEgressHTTPSAny, "") + if err != nil || len(l.Rows) != 2 || l.Rows[0][0] != "10.0.0.9" || l.Rows[1][0] != "10.0.0.10" { // numeric order + t.Fatalf("any: %+v %v", l, err) + } + if l.Rows[0][2] != "v12" || l.Rows[0][3] != "2 из 2" || l.Rows[1][3] != "1 из 2" || l.Rows[1][4] != "b.test" { + t.Errorf("any rows: %+v", l.Rows) + } + l, _ = an.List(ListEgressHTTPSAll, "") + if len(l.Rows) != 1 || l.Rows[0][3] != "2" || l.Rows[0][4] != "a.test, b.test" { + t.Errorf("all: %+v", l.Rows) + } + l, _ = an.List(ListIngressSSHAll, "") + if len(l.Rows) != 1 || l.Rows[0][2] != "rxmsk, rxyc" || l.Rows[0][3] != "таймаут" { // sites in index order + t.Errorf("ssh all: %+v", l.Rows) + } + l, _ = an.List(ListError, "SSH: таймаут") + if len(l.Rows) != 2 || l.Rows[0][2] != "rxmsk" || l.Rows[0][5] != "провал, после вердикта" || l.Rows[1][5] != "провал, в вердикте" { + t.Errorf("error list: %+v", l.Rows) + } + if _, err := an.List(ListError, ""); err == nil { + t.Error("an error list needs a class") + } + if _, err := an.List("nonsense", ""); err == nil { + t.Error("unknown list must fail") + } + for in, want := range map[string]string{"vkiplab-v12": "v12", "validator": "validator", "": ""} { + if got := ShortValidator(in); got != want { + t.Errorf("ShortValidator(%q) = %q", in, got) + } + } +} diff --git a/internal/analytics/lists.go b/internal/analytics/lists.go new file mode 100644 index 0000000..f8efea4 --- /dev/null +++ b/internal/analytics/lists.go @@ -0,0 +1,126 @@ +package analytics + +import ( + "fmt" + "net/netip" + "sort" + "strings" + + "cloudipvalidator/internal/db" +) + +// List kinds served by Analysis.List. +const ( + ListEgressHTTPSAny = "egress_https_any" + ListEgressHTTPSAll = "egress_https_all" + ListIngressSSHAny = "ingress_ssh_any" + ListIngressSSHAll = "ingress_ssh_all" + ListError = "error" +) + +// List is a table of addresses behind one indicator or one error class. +type List struct { + Kind string `json:"kind"` + Class string `json:"class,omitempty"` + Columns []string `json:"columns"` + Rows [][]string `json:"rows"` +} + +// ErrUnknownList is returned for a list kind that does not exist. +type ErrUnknownList string + +func (e ErrUnknownList) Error() string { return fmt.Sprintf("unknown list %q", string(e)) } + +// List builds the table for a kind; class is only used with ListError. +func (an *Analysis) List(kind, class string) (*List, error) { + l := &List{Kind: kind, Class: class, Rows: [][]string{}} + switch kind { + case ListEgressHTTPSAny, ListEgressHTTPSAll: + l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "https-проверок", "Проваленные цели"} + if kind == ListEgressHTTPSAny { + l.Columns[3] = "Провалено https" + } + for _, a := range an.sorted() { + if a.https.n == 0 || a.https.ok == a.https.n || (kind == ListEgressHTTPSAll && a.https.ok != 0) { + continue + } + targets := append([]string(nil), a.https.failedTargets...) + sort.Strings(targets) + count := fmt.Sprint(a.https.n) + if kind == ListEgressHTTPSAny { + count = fmt.Sprintf("%d из %d", a.https.n-a.https.ok, a.https.n) + } + l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, ShortValidator(a.https.validator), count, strings.Join(targets, ", ")}) + } + case ListIngressSSHAny, ListIngressSSHAll: + l.Columns = []string{"Адрес", "Подсеть", "Провалено ssh", "Площадки с провалом", "Ошибка"} + if kind == ListIngressSSHAll { + l.Columns = []string{"Адрес", "Подсеть", "Площадки с провалом ssh", "Ошибка"} + } + for _, a := range an.sorted() { + if a.ssh.n == 0 || a.ssh.ok == a.ssh.n || (kind == ListIngressSSHAll && a.ssh.ok != 0) { + continue + } + sites := an.sortSites(a.ssh.sites) + errs := make([]string, 0, len(a.ssh.errs)) + for e := range a.ssh.errs { + errs = append(errs, e) + } + sort.Strings(errs) + if kind == ListIngressSSHAny { + l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, fmt.Sprintf("%d из %d", len(a.ssh.sites), a.ssh.n), strings.Join(sites, ", "), strings.Join(errs, ", ")}) + } else { + l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")}) + } + } + case ListError: + if class == "" { + return nil, ErrUnknownList("error without class") + } + l.Columns = []string{"Адрес", "Подсеть", "Площадка", "Валидатор", "Вердикт адреса", "Статус проверки"} + for _, a := range an.sorted() { + fs := append([]failedIngress(nil), a.failedIngress...) + sort.SliceStable(fs, func(i, j int) bool { return an.siteIndex(fs[i].site) < an.siteIndex(fs[j].site) }) + for _, f := range fs { + if f.class != class { + continue + } + status := "провал, в вердикте" + if f.late { + status = "провал, после вердикта" + } + l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, f.site, ShortValidator(f.validator), a.res.Verdict, status}) + } + } + default: + return nil, ErrUnknownList(kind) + } + return l, nil +} + +// sorted returns the non-cancelled addresses in numeric address order. +func (an *Analysis) sorted() []*addr { + out := make([]*addr, 0, len(an.addrs)) + for _, a := range an.addrs { + if a.res.Verdict != db.ResultCancelled { + out = append(out, a) + } + } + sort.Slice(out, func(i, j int) bool { + x, errX := netip.ParseAddr(out[i].res.IPAddress) + y, errY := netip.ParseAddr(out[j].res.IPAddress) + if errX != nil || errY != nil { + return out[i].res.IPAddress < out[j].res.IPAddress + } + return x.Less(y) + }) + return out +} + +func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) } + +func (an *Analysis) sortSites(sites []string) []string { + out := append([]string(nil), sites...) + sort.SliceStable(out, func(i, j int) bool { return an.siteIndex(out[i]) < an.siteIndex(out[j]) }) + return out +} diff --git a/internal/analytics/load.go b/internal/analytics/load.go new file mode 100644 index 0000000..cb3ba13 --- /dev/null +++ b/internal/analytics/load.go @@ -0,0 +1,39 @@ +package analytics + +import ( + "context" + + "cloudipvalidator/internal/db" +) + +// Load reads a finished run from the database and computes its analysis. +func Load(ctx context.Context, d *db.DB, runID int64) (*Analysis, error) { + run, err := d.GetRun(ctx, runID) + if err != nil { + return nil, err + } + results, err := d.ListRunResults(ctx, runID) + if err != nil { + return nil, err + } + subnets, err := d.ListSubnets(ctx) + if err != nil { + return nil, err + } + sites, err := d.ListSites(ctx) + if err != nil { + return nil, err + } + names := map[int]string{} + for _, s := range sites { + names[s.Index] = s.SiteID + } + rechecked, err := d.CountRecheckedInRun(ctx, runID) + if err != nil { + return nil, err + } + return Compute(Input{ + Run: *run, Results: results, Subnets: subnets, SiteNames: names, Rechecked: rechecked, + Each: func(fn func(db.RunCheck)) error { return d.EachRunCheck(ctx, runID, fn) }, + }) +} diff --git a/internal/dashboard/client.go b/internal/dashboard/client.go index ebe152d..3ed0e40 100644 --- a/internal/dashboard/client.go +++ b/internal/dashboard/client.go @@ -244,6 +244,8 @@ func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) { type registryQuery struct { Q string LastResult string + Run int64 // only addresses with a result in this run + Subnet string // only addresses inside this CIDR Limit int Offset int } @@ -264,6 +266,12 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr if q.LastResult != "" { v.Set("last_result", q.LastResult) } + if q.Run > 0 { + v.Set("run", strconv.FormatInt(q.Run, 10)) + } + if q.Subnet != "" { + v.Set("subnet", q.Subnet) + } var out registryPage err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out) return out, err @@ -392,3 +400,104 @@ func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) { err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out) return out, err } + +// analyticsRun is one entry of GET /admin/analytics/runs (the run selector). +type analyticsRun struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + FinalizedAt *time.Time `json:"finalized_at"` + Addresses int `json:"addresses"` + Pass int `json:"pass"` + Partial int `json:"partial"` + Fail int `json:"fail"` + Cancelled int `json:"cancelled"` + Total int `json:"total"` + Pending int `json:"pending"` +} + +func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) { + var out []analyticsRun + err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out) + return out, err +} + +// GetAnalyticsReport returns the analytics of one finished run as the raw +// JSON control-api computed; the page's script reads it as it is. +func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) { + var out json.RawMessage + err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out) + return out, err +} + +func analyticsListPath(runID int64, kind, class string, csv bool) string { + v := url.Values{} + if class != "" { + v.Set("class", class) + } + if csv { + v.Set("format", "csv") + } + p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind) + if len(v) > 0 { + p += "?" + v.Encode() + } + return p +} + +// GetAnalyticsList returns one address table (JSON) of a run. +func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) { + var out json.RawMessage + err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out) + return out, err +} + +// GetAnalyticsListCSV returns the CSV file of one address table, with the +// file name control-api proposed. +func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil) + if err != nil { + return nil, "", fmt.Errorf("build request: %w", err) + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, "", &apiErr{Status: 0, Message: err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + msg := string(body) + var er errorResponse + if json.Unmarshal(body, &er) == nil && er.Error != "" { + msg = er.Error + } + return nil, "", &apiErr{Status: resp.StatusCode, Message: msg} + } + return body, resp.Header.Get("Content-Disposition"), nil +} + +// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets). +type subnetEntry struct { + CIDR string `json:"cidr"` + Label string `json:"label,omitempty"` +} + +type subnetList struct { + Subnets []subnetEntry `json:"subnets"` +} + +func (c *client) GetSubnets(ctx context.Context) (subnetList, error) { + var out subnetList + err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out) + return out, err +} + +func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) { + var out subnetList + err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out) + return out, err +} diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 3488ffc..a8e775f 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -37,6 +37,14 @@ type fakeControlAPI struct { inboundICMP bool historyRetentionCycles int + + // Analytics: the run selector, the report JSON per run, the lists per + // "run/kind[/class]" and the subnet list of /settings. + runs []analyticsRun + reports map[int64]string + lists map[string]string + subnets subnetList + analyticsReqs []string // scanFreeAddresses is what POST /ips/scan "discovers" — tests set it // directly rather than this fake reimplementing OpenStack floating-IP // filtering (already covered by internal/orchestrator's own tests). @@ -469,6 +477,76 @@ func (f *fakeControlAPI) handler() http.Handler { writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]}) }) + mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + out := f.subnets + if out.Subnets == nil { + out.Subnets = []subnetEntry{} + } + writeJSON(w, http.StatusOK, out) + }) + mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) { + var in subnetList + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeAPIErr(w, http.StatusBadRequest, err.Error()) + return + } + for _, s := range in.Subnets { + if !strings.Contains(s.CIDR, "/") { + writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR") + return + } + } + f.mu.Lock() + defer f.mu.Unlock() + f.subnets = in + writeJSON(w, http.StatusOK, in) + }) + mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + out := f.runs + if out == nil { + out = []analyticsRun{} + } + writeJSON(w, http.StatusOK, out) + }) + mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) + rep, ok := f.reports[id] + if !ok { + writeAPIErr(w, http.StatusNotFound, "run not found") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(rep)) + }) + mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI()) + key := r.PathValue("id") + "/" + r.PathValue("kind") + if c := r.URL.Query().Get("class"); c != "" { + key += "/" + c + } + l, ok := f.lists[key] + if !ok { + writeAPIErr(w, http.StatusNotFound, "unknown list") + return + } + if r.URL.Query().Get("format") == "csv" { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`) + _, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n")) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(l)) + }) mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/dashboard/handlers_analytics.go b/internal/dashboard/handlers_analytics.go new file mode 100644 index 0000000..0508407 --- /dev/null +++ b/internal/dashboard/handlers_analytics.go @@ -0,0 +1,254 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + "time" +) + +// runOption is one entry of the run selector. +type runOption struct { + ID int64 + Label string + Disabled bool + Selected bool +} + +type analyticsPageData struct { + PageData + Runs []runOption + RunID int64 + PrevURL string // older run, "" when there is none + NextURL string // newer run + // DataJSON is the page's data for analytics.js (run meta, labels, report), + // HTML-safe JSON. + DataJSON template.JS + HasRun bool +} + +// analyticsMeta is what analytics.js needs besides the report. +type analyticsMeta struct { + RunID int64 `json:"run_id"` + Kind string `json:"kind"` + Start string `json:"start"` + End string `json:"end"` + Duration string `json:"duration"` + Rechecked int `json:"rechecked"` + ListURL string `json:"list_url"` + CSVURL string `json:"csv_url"` + Registry string `json:"registry_url"` +} + +// handleAnalyticsPage renders the analytics of one finished run: ?run=ID, by +// default the newest finished run. The run selector lists every run, the open +// one disabled; nothing of any other run is on the page. +func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) { + data := analyticsPageData{} + data.ActiveNav = "analytics" + + runs, err := s.CA.ListAnalyticsRuns(r.Context()) + if err != nil { + data.Banner = bannerFor(err) + s.renderPage(w, r, "analytics_page", data) + return + } + want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64) + var chosen *analyticsRun + for i := range runs { // newest first + if runs[i].State != "finalized" || runs[i].Addresses == 0 { + continue + } + if want == 0 || runs[i].ID == want { + chosen = &runs[i] + break + } + } + var finished []analyticsRun // newest first + for _, x := range runs { + if x.State == "finalized" && x.Addresses > 0 { + finished = append(finished, x) + } + } + for _, x := range runs { + if x.State == "finalized" && x.Addresses == 0 { + continue // nothing to show for it, hide + } + opt := runOption{ID: x.ID, Label: runLabel(x), Disabled: x.State != "finalized"} + if chosen != nil && x.ID == chosen.ID { + opt.Selected = true + } + data.Runs = append(data.Runs, opt) + } + if chosen == nil { + if want != 0 { + data.Banner = bannerData{Message: fmt.Sprintf("Запуск %d не найден или ещё не завершён.", want), Client: true} + } + s.renderPage(w, r, "analytics_page", data) + return + } + data.RunID = chosen.ID + for i, x := range finished { + if x.ID == chosen.ID { + if i+1 < len(finished) { + data.PrevURL = "/analytics?run=" + strconv.FormatInt(finished[i+1].ID, 10) + } + if i > 0 { + data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10) + } + } + } + + report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID) + if err != nil { + data.Banner = bannerFor(err) + s.renderPage(w, r, "analytics_page", data) + return + } + var info struct { + Run struct { + Rechecked int `json:"rechecked"` + } `json:"run"` + } + _ = json.Unmarshal(report, &info) + id := strconv.FormatInt(chosen.ID, 10) + meta := analyticsMeta{ + RunID: chosen.ID, Kind: kindLabel(chosen.Kind), + Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked, + ListURL: "/analytics/lists/", + CSVURL: "/analytics/csv/", + Registry: "/registry?run=" + id, + } + if chosen.FinalizedAt != nil { + meta.End = fmtShort(*chosen.FinalizedAt) + meta.Duration = fmtRunDuration(chosen.FinalizedAt.Sub(chosen.StartedAt)) + } + payload, err := json.Marshal(struct { + Meta analyticsMeta `json:"meta"` + Report json.RawMessage `json:"report"` + }{meta, report}) + if err != nil { + data.Banner = bannerFor(err) + s.renderPage(w, r, "analytics_page", data) + return + } + data.HasRun = true + data.DataJSON = template.JS(payload) + s.renderPage(w, r, "analytics_page", data) +} + +func parseRunParam(r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64) + return id, err == nil && id > 0 +} + +// handleAnalyticsList proxies one address table of a run as JSON. +func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) { + id, ok := parseRunParam(r) + if !ok { + http.Error(w, "run is required", http.StatusBadRequest) + return + } + out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class")) + if err != nil { + writeProxyError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(out) +} + +// handleAnalyticsCSV proxies the CSV file of one address table as a download. +func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) { + id, ok := parseRunParam(r) + if !ok { + http.Error(w, "run is required", http.StatusBadRequest) + return + } + body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class")) + 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) +} + +func writeProxyError(w http.ResponseWriter, err error) { + status := http.StatusBadGateway + if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 { + status = ae.Status + } + http.Error(w, err.Error(), status) +} + +func kindLabel(kind string) string { + if kind == "auto" { + return "авто" + } + return "ручной" +} + +// groupThousands writes n with a space between thousands: 6440 -> "6 440". +func groupThousands(n int) string { + s := strconv.Itoa(n) + if len(s) <= 3 { + return s + } + var b strings.Builder + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + b.WriteString(" ") + } + b.WriteRune(c) + } + return b.String() +} + +// fmtShort is a run timestamp as the page shows it: 02.10.2026 13:47. +func fmtShort(t time.Time) string { return t.Local().Format("02.01.2006 15:04") } + +// fmtDuration is "8 ч 42 мин", "42 мин", "под минуту". +func fmtRunDuration(d time.Duration) string { + m := int(d.Round(time.Minute) / time.Minute) + switch { + case m <= 0: + return "меньше минуты" + case m < 60: + return fmt.Sprintf("%d мин", m) + } + return fmt.Sprintf("%d ч %d мин", m/60, m%60) +} + +// runLabel is the text of a run in the selector, e.g. +// "02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass"; an open run reads +// "03.10 15:30 → идёт · ручной · 120 из 800 · недоступен". +func runLabel(x analyticsRun) string { + start := x.StartedAt.Local().Format("02.01 15:04") + if x.State != "finalized" { + return fmt.Sprintf("%s → идёт · %s · %s из %s · недоступен", start, kindLabel(x.Kind), + groupThousands(x.Total-x.Pending), groupThousands(x.Total)) + } + end := "—" + if x.FinalizedAt != nil { + e := x.FinalizedAt.Local() + if e.Format("02.01") == x.StartedAt.Local().Format("02.01") { + end = e.Format("15:04") + } else { + end = e.Format("02.01 15:04") + } + } + pass := 0 + if x.Addresses > 0 { + pass = int(float64(x.Pass)/float64(x.Addresses)*100 + 0.5) + } + return fmt.Sprintf("%s → %s · %s · %s адр. · %d%% pass", start, end, kindLabel(x.Kind), groupThousands(x.Addresses), pass) +} diff --git a/internal/dashboard/handlers_analytics_test.go b/internal/dashboard/handlers_analytics_test.go new file mode 100644 index 0000000..0c1ad5e --- /dev/null +++ b/internal/dashboard/handlers_analytics_test.go @@ -0,0 +1,234 @@ +package dashboard + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func fakeRun(id int64, state string, addresses, pass int) analyticsRun { + start := time.Date(2026, 10, 2, 13, 47, 0, 0, time.UTC) + end := start.Add(8*time.Hour + 42*time.Minute) + r := analyticsRun{ID: id, Kind: "manual", State: state, StartedAt: start, Addresses: addresses, Pass: pass, + Partial: addresses - pass, Total: addresses} + if state == "finalized" { + r.FinalizedAt = &end + } else { + r.Pending = 80 + r.Total = addresses + r.Pending + } + return r +} + +// reportWith is the minimal report JSON the page needs; addresses is a marker +// that tells the runs apart in the page source. +func reportWith(addresses string) string { + return `{"run":{"rechecked":0},"summary":{"addresses":` + addresses + `,"pass":1,"partial":0,"fail":0,"cancelled":0,` + + `"egress_ok":1,"ingress_ok":1,"egress_https_any_failed":0,"egress_https_all_failed":0,"egress_https_all_targets_failed":0,` + + `"ingress_ssh_any_failed":0,"ingress_ssh_all_failed":0,"addresses_per_minute":1},"reasons":[],"quality":{},"subnets":[],` + + `"targets":{"types":[],"targets":[],"failed":{}},"matrix":{},"sites":{"types":[],"rows":[]},"errors":[],"validators":[]}` +} + +func analyticsFake(t *testing.T) (*fakeControlAPI, *httptest.Server) { + t.Helper() + fake, caURL := newFakeControlAPI(t) + fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(2, "finalized", 900, 300), fakeRun(1, "finalized", 6440, 1962)} + fake.reports = map[int64]string{1: reportWith("6440"), 2: reportWith("900")} + fake.lists = map[string]string{ + "1/egress_https_any": `{"kind":"egress_https_any","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`, + "1/error/SSH: таймаут": `{"kind":"error","class":"SSH: таймаут","columns":["Адрес"],"rows":[["1.2.3.4"]]}`, + } + return fake, newTestServer(t, caURL) +} + +// The page shows one run, by default the newest finished one, and asks +// control-api for that run only; the selector lists every run, the open one +// disabled. +func TestAnalyticsPageShowsOneRun(t *testing.T) { + fake, ts := analyticsFake(t) + + page := get(t, ts, "/analytics") + for _, want := range []string{ + `id="an-run"`, `id="analytics-data"`, `"addresses":900`, // newest finished run (2) + "идёт · ручной · 120 из 200 · недоступен", + "6 440 адр. · 30% pass", // run 1's option, from the run list + `/analytics?run=1`, // the older run is one step back + } { + if !strings.Contains(page, want) { + t.Fatalf("expected %q in the page, got:\n%s", want, page) + } + } + if strings.Contains(page, `"addresses":6440`) { + t.Fatalf("the data of run 1 is on the page of run 2") + } + if !strings.Contains(page, `value="3" disabled`) { + t.Fatalf("the open run must be listed but disabled:\n%s", page) + } + for _, r := range fake.analyticsReqs { + if strings.Contains(r, "/runs/1") || strings.Contains(r, "/runs/3") { + t.Fatalf("the page must request only the chosen run, got %v", fake.analyticsReqs) + } + } + + other := get(t, ts, "/analytics?run=1") + if !strings.Contains(other, `"addresses":6440`) || strings.Contains(other, `"addresses":900`) { + t.Fatalf("run 1 page must carry only run 1's data:\n%s", other) + } +} + +func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) { + _, caURL := newFakeControlAPI(t) + ts := newTestServer(t, caURL) + page := get(t, ts, "/analytics") + if !strings.Contains(page, "Запусков проверки пока нет") || strings.Contains(page, `id="analytics-data"`) { + t.Fatalf("expected the empty state, got:\n%s", page) + } + + _, ts = analyticsFake(t) + for _, run := range []string{"99", "3"} { // unknown, and the open one + page = get(t, ts, "/analytics?run="+run) + if !strings.Contains(page, "не найден или ещё не завершён") { + t.Fatalf("run %s: expected a warning, got:\n%s", run, page) + } + } +} + +func TestAnalyticsListProxyAndCSV(t *testing.T) { + _, ts := analyticsFake(t) + + resp, err := http.Get(ts.URL + "/analytics/lists/egress_https_any?run=1") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.2.3.4"`) { + t.Fatalf("list: %d %s", resp.StatusCode, body) + } + + q := url.Values{"run": {"1"}, "class": {"SSH: таймаут"}} + resp, err = http.Get(ts.URL + "/analytics/lists/error?" + q.Encode()) + if err != nil { + t.Fatal(err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"class":"SSH: таймаут"`) { + t.Fatalf("error list: %d %s", resp.StatusCode, body) + } + + resp, err = http.Get(ts.URL + "/analytics/csv/egress_https_any?run=1") + if err != nil { + t.Fatal(err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") || + !strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="egress_https_any_run1.csv"`) { + t.Fatalf("csv: %d %v %s", resp.StatusCode, resp.Header, body) + } + + for path, want := range map[string]int{ + "/analytics/lists/egress_https_any": http.StatusBadRequest, // no run + "/analytics/csv/egress_https_any?run=abc": http.StatusBadRequest, + "/analytics/lists/nonsense?run=1": http.StatusNotFound, // control-api says unknown list + } { + resp, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want) + } + } +} + +// The sidebar: no window-chrome dots next to the logo, the Analytics entry, +// and the link state, theme toggle and logout above the navigation. +func TestSidebarSessionBlockOnTop(t *testing.T) { + _, caURL := newFakeControlAPI(t) + _, ts := newAuthTestServer(t, caURL, nil) + cookie := login(t, ts) + _, page := doReq(t, ts, reqOpts{path: "/overview", cookie: cookie}) + + if strings.Contains(page, "brand-chrome") { + t.Fatalf("the three dots next to the logo are back") + } + iBrand := strings.Index(page, `class="brand"`) + iAPI := strings.Index(page, `class="session-api"`) + iLogout := strings.Index(page, `action="/logout"`) + iNav := strings.Index(page, `class="nav-groups"`) + iFoot := strings.Index(page, "sidebar-foot") + if !(iBrand >= 0 && iBrand < iAPI && iAPI < iLogout && iLogout < iNav) || iFoot >= 0 { + t.Fatalf("expected brand < control-api state < logout < navigation (and no old footer): %d %d %d %d foot=%d", iBrand, iAPI, iLogout, iNav, iFoot) + } + for _, link := range []string{`href="/analytics"`, `href="/registry"`, `href="/settings"`} { + if !strings.Contains(page, link) { + t.Fatalf("missing nav link %s", link) + } + } + if strings.Contains(page, "pulse-dot down") { + t.Fatalf("the link state must be fine while control-api answers") + } +} + +// The link indicator turns red when control-api cannot be reached. +func TestSidebarShowsLostControlAPI(t *testing.T) { + ts := newTestServer(t, "http://127.0.0.1:1") // nothing listens there + page := get(t, ts, "/overview") + if !strings.Contains(page, "pulse-dot down") || !strings.Contains(page, "нет связи") { + t.Fatalf("expected the lost-link indicator, got:\n%s", page) + } +} + +func TestSettingsSubnetsForm(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + ts := newTestServer(t, caURL) + + body := postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"83.166.248.0/21 москва\n\n 10.0.0.0/8\n"}}) + if len(fake.subnets.Subnets) != 2 || + fake.subnets.Subnets[0] != (subnetEntry{CIDR: "83.166.248.0/21", Label: "москва"}) || + fake.subnets.Subnets[1] != (subnetEntry{CIDR: "10.0.0.0/8"}) { + t.Fatalf("subnets saved: %+v", fake.subnets) + } + if !strings.Contains(body, "83.166.248.0/21 москва") || !strings.Contains(body, "10.0.0.0/8") { + t.Fatalf("the saved list must come back in the form:\n%s", body) + } + + body = postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"not-a-cidr"}}) + if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "not a CIDR") { + t.Fatalf("expected the validation error in the banner:\n%s", body) + } +} + +// The drill-down from the analytics page: run and subnet go to control-api, +// come back as hidden fields and a visible chip; a malformed subnet is dropped. +func TestRegistryDrillDownFromAnalytics(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + ts := newTestServer(t, caURL) + + page := get(t, ts, "/registry?run=2&subnet="+url.QueryEscape("10.0.0.0/24")) + if len(fake.registryQueries) == 0 { + t.Fatal("no registry request") + } + last := fake.registryQueries[len(fake.registryQueries)-1] + if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") { + t.Fatalf("control-api request %q lacks the run or subnet", last) + } + for _, want := range []string{`name="run" value="2"`, `name="subnet" value="10.0.0.0/24"`, "запуск 2", "подсеть 10.0.0.0/24", "сбросить фильтр"} { + if !strings.Contains(page, want) { + t.Fatalf("expected %q in:\n%s", want, page) + } + } + + page = get(t, ts, "/registry?subnet=garbage") + last = fake.registryQueries[len(fake.registryQueries)-1] + if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") { + t.Fatalf("a malformed subnet must be ignored: %q", last) + } +} diff --git a/internal/dashboard/handlers_registry.go b/internal/dashboard/handlers_registry.go index 56d9f89..21d2554 100644 --- a/internal/dashboard/handlers_registry.go +++ b/internal/dashboard/handlers_registry.go @@ -2,7 +2,9 @@ package dashboard import ( "net/http" + "net/netip" "net/url" + "strconv" "strings" ) @@ -11,6 +13,8 @@ type registryPageData struct { Items []registryItem Query string StatusFilter string + Run int64 // drill-down from the analytics page + Subnet string Page, PerPage int Total int Pager pagerData @@ -37,7 +41,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { } perPage := parsePerPage(r.URL.Query().Get("per_page")) page := parsePage(r.URL.Query().Get("page")) - query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage} + 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} res, err := s.CA.ListRegistryPage(r.Context(), query) if err == nil { @@ -54,7 +66,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) { if status != "" { params.Set("status", status) } + if run > 0 { + params.Set("run", strconv.FormatInt(run, 10)) + } + if subnet != "" { + params.Set("subnet", subnet) + } data := registryPageData{ + Run: run, + Subnet: subnet, Items: res.Items, Query: q, StatusFilter: status, diff --git a/internal/dashboard/handlers_settings.go b/internal/dashboard/handlers_settings.go index d731062..077474c 100644 --- a/internal/dashboard/handlers_settings.go +++ b/internal/dashboard/handlers_settings.go @@ -14,6 +14,8 @@ type settingsPageData struct { Inbound inboundChecksDTO // AutoCycle is the automatic-check-cycle panel's status/parameters. AutoCycle autoCycleDTO + // Subnets is the list the analytics page groups addresses by. + Subnets subnetList } func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { @@ -26,7 +28,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { if err == nil { err = autoCycleErr } - data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle} + subnets, subnetsErr := s.CA.GetSubnets(r.Context()) + if err == nil { + err = subnetsErr + } + data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets} data.ActiveNav = "settings" data.Banner = bannerFor(err) s.renderPage(w, r, "settings_page", data) @@ -49,7 +55,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti if actionErr == nil { actionErr = autoCycleErr } - s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr) + subnets, subnetsErr := s.CA.GetSubnets(r.Context()) + if actionErr == nil { + actionErr = subnetsErr + } + s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr) } func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) { @@ -102,6 +112,26 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request) s.renderSettingsForm(w, r, err) } +// handleSubnetsPut saves the subnet list from the textarea of /settings: one +// subnet per line, CIDR first and an optional label after a space. +func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err)) + return + } + list := subnetList{Subnets: []subnetEntry{}} + for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cidr, label, _ := strings.Cut(line, " ") + list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)}) + } + _, err := s.CA.PutSubnets(r.Context(), list) + s.renderSettingsForm(w, r, err) +} + // parseMinutes converts a form field holding a (possibly fractional) number // of minutes into whole seconds. func parseMinutes(raw string) (int, error) { diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go index c162e15..16e8d04 100644 --- a/internal/dashboard/routes.go +++ b/internal/dashboard/routes.go @@ -29,6 +29,10 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /registry", s.handleRegistryPage) mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail) + mux.HandleFunc("GET /analytics", s.handleAnalyticsPage) + mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList) + mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV) + mux.HandleFunc("GET /validators", s.handleValidatorsPage) mux.HandleFunc("POST /validators", s.handleValidatorCreate) mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate) @@ -52,6 +56,7 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /settings", s.handleSettingsPage) mux.HandleFunc("PUT /settings", s.handleSettingsPut) mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut) + mux.HandleFunc("PUT /settings/subnets", s.handleSubnetsPut) mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut) mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart) mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop) diff --git a/internal/dashboard/static/analytics.css b/internal/dashboard/static/analytics.css new file mode 100644 index 0000000..a55b58b --- /dev/null +++ b/internal/dashboard/static/analytics.css @@ -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); } +} diff --git a/internal/dashboard/static/analytics.js b/internal/dashboard/static/analytics.js new file mode 100644 index 0000000..3db3a3b --- /dev/null +++ b/internal/dashboard/static/analytics.js @@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) + '% адресов', 'успех', ''], + ['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', 'частично', ''], + ['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной', + S.fail ? 'провал' : 'нет', ''], + ['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''], + ['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''], + ['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна', + 'прикладной список →', 'egress_https_any'], + ['Ingress ssh: есть провалы', fmt(S.ingress_ssh_any_failed), pct1(S.ingress_ssh_any_failed, S.addresses) + '% адресов, хотя бы с одной площадки', + 'прикладной список →', 'ingress_ssh_any'], + ['Egress https: все провалены', fmt(S.egress_https_all_failed), pct1(S.egress_https_all_failed, S.addresses) + '% адресов, по всем целям: ' + fmt(S.egress_https_all_targets_failed), + 'прикладной список →', 'egress_https_all'], + ['Ingress ssh: все провалены', fmt(S.ingress_ssh_all_failed), pct1(S.ingress_ssh_all_failed, S.addresses) + '% адресов, ssh провален со всех площадок', + 'прикладной список →', 'ingress_ssh_all'], + ['Длительность', esc(M.duration), fmt(S.addresses_per_minute) + ' адр./мин', '', ''], + ['Поздние результаты', fmt(late), 'у ' + fmt(Q.late_failed_addresses_at_pass) + ' адресов, после вердикта', + late ? 'внимание' : 'в норме', ''] + ]; + $('an-kpis').innerHTML = tiles.map(function (t) { + var inner = '
' + t[0] + '
' + t[1] + '
' + t[2] + ' ' + t[3] + '
'; + return t[4] + ? '' + : '
' + inner + '
'; + }).join(''); + } + + function bar(v, max) { + return '
'; + } + + function renderReasons() { + var max = Math.max.apply(null, R.reasons.map(function (x) { return x.count; }).concat([1])); + $('an-reasons').innerHTML = R.reasons.map(function (x) { + return '
' + esc(x.name) + '' + bar(x.count, max) + '' + fmt(x.count) + '
'; + }).join(''); + } + + function renderQuality() { + var errTotal = Q.ingress_failed_checks; + var rows = [ + ['Поздние провалы у адресов с вердиктом pass', fmt(Q.late_failed_checks_at_pass) + ' / ' + fmt(Q.late_failed_addresses_at_pass) + ' адр.'], + ['Провалы ingress, пришедшие после вердикта', fmt(Q.ingress_failed_late) + ' из ' + fmt(errTotal)], + ['Неполный набор проверок', fmt(Q.incomplete_addresses) + ' адр.'], + ['Вердикт pass, но есть проваленные проверки', fmt(Q.pass_with_failed_addresses) + ' адр.'], + ['pass по вердикту → по фактическим проверкам', fmt(S.pass) + ' → ' + fmt(Q.pass_by_facts)] + ]; + $('an-dq').innerHTML = rows.map(function (r) { return '
' + r[0] + '' + r[1] + '
'; }).join(''); + } + + function pbar(a, n) { + var p = pct(a, n); + return '
' + p + '%
'; + } + + function renderSubnets() { + var list = R.subnets.slice(); + list.sort(state.sort === 'worst' + ? function (a, b) { return a.pass / a.addresses - b.pass / b.addresses || b.addresses - a.addresses; } + : function (a, b) { return b.addresses - a.addresses; }); + var shown = state.all ? list : list.slice(0, 10); + $('an-allsub').textContent = state.all ? 'свернуть до 10' : 'показать все ' + list.length; + $('an-allsub').hidden = list.length <= 10; + $('an-subtbl').innerHTML = 'ПодсетьАдресовpassEgress OKIngress OK' + + shown.map(function (s) { + var name = s.label ? s.cidr + ' · ' + s.label : s.cidr; + var link = s.cidr === 'прочие' ? esc(name) : '' + esc(name) + ''; + return '' + link + '' + fmt(s.addresses) + '' + + '' + pbar(s.pass, s.addresses) + '' + + '' + pbar(s.egress_ok, s.addresses) + '' + + '' + pbar(s.ingress_ok, s.addresses) + ''; + }).join('') + ''; + } + + function heatClass(v) { return 'an-h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); } + + function renderTypes() { + $('an-types').innerHTML = R.targets.types.map(function (t) { + return ''; + }).join(''); + } + + function renderTargets() { + var t = state.type, T = R.targets, vals = T.failed[t] || []; + var max = Math.max.apply(null, vals.concat([1])); + $('an-targets').innerHTML = T.targets.map(function (n, i) { + return '
' + esc(n) + '' + bar(vals[i], max) + '' + fmt(vals[i]) + ' · ' + pct(vals[i], S.addresses) + '%
'; + }).join(''); + var rows = (R.matrix[t] || []).slice(0, state.all ? 100000 : 10); + $('an-matrixwrap').hidden = !rows.length; + $('an-hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map(function (l, i) { + return ' ' + l + '%'; + }).join(''); + $('an-matrix').innerHTML = 'Подсетьpartial' + T.targets.map(function (x) { + return '' + esc(x.replace('.com', '').replace('.org', '')) + ''; + }).join('') + '' + rows.map(function (m) { + var link = m.cidr === 'прочие' ? esc(m.cidr) : '' + esc(m.cidr) + ''; + return '' + link + '' + fmt(m.partial) + '' + m.percent.map(function (v, j) { + return '' + v + '%'; + }).join('') + ''; + }).join('') + ''; + $('an-matrixnote').textContent = 'Доля адресов partial подсети, провалившие ' + t + ' к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.'; + } + + function renderSites() { + var T = R.sites; + $('an-sites').innerHTML = 'Площадка' + T.types.map(function (t) { return '' + esc(t) + ''; }).join('') + '' + + T.rows.map(function (row) { + return '' + esc(row.site) + '' + row.stats.map(function (st, i) { + return '' + pct1(st.total - st.ok, st.total) + '% провал'; + }).join('') + ''; + }).join('') + ''; + } + + function renderErrors() { + var max = Math.max.apply(null, R.errors.map(function (e) { return e.count; }).concat([1])); + $('an-errs').innerHTML = R.errors.length ? R.errors.map(function (e) { + return ''; + }).join('') : '

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

'; + } + + function renderValidators() { + var V = R.validators; + var f = V.map(function (v) { return v.total ? 1 - v.ok / v.total : 0; }); + var avg = f.length ? f.reduce(function (a, b) { return a + b; }, 0) / f.length : 0; + $('an-vals').innerHTML = f.map(function (x, i) { + return '
'; + }).join('') + '
'; + $('an-vfirst').textContent = V.length ? 'валидатор ' + vshort(V[0].validator) : ''; + $('an-vlast').textContent = V.length > 1 ? 'валидатор ' + vshort(V[V.length - 1].validator) : ''; + $('an-vavg').textContent = V.length ? 'среднее ' + Math.round(avg * 100) + '% (шкала 0–100%)' : 'нет данных'; + } + + /* ---- address lists in the dialog ---- */ + var VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.'; + var STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.'; + var VERDICT_VAL = { + pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.', + partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.', + fail: 'fail: все проверки адреса провалены.' + }; + function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; } + + var 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 = '' + m.cols.map(function (c, i) { + return hint[i] ? '' + esc(c) + '' : '' + esc(c) + ''; + }).join('') + '' + m.rows.map(function (x) { + return '' + x.map(function (v, i) { + var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : ''; + return '' + esc(v) + ''; + }).join('') + ''; + }).join('') + ''; + $('an-dlg-msg').textContent = ''; + } + + function loadList(kind, cls, meta) { + $('an-dlg-title').textContent = meta.title; + $('an-dlg-note').textContent = 'Загрузка…'; + $('an-dlg-dist').hidden = true; + $('an-dlg-tbl').innerHTML = ''; + $('an-dlg-msg').textContent = ''; + cur = null; + openDialog(); + return fetch(listURL(M.list_url, kind, cls), { headers: { Accept: 'application/json' }, credentials: 'same-origin' }) + .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(function (l) { return l; }) + .catch(function (e) { + $('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message; + return null; + }); + } + + function runLabel() { + var o = document.getElementById('an-run'); + return o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id; + } + + function openIndicator(kind) { + var meta = LISTS[kind]; + loadList(kind, '', meta).then(function (l) { + if (!l) return; + fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' }); + }); + } + + function openError(cls) { + loadList('error', cls, { title: 'Ingress: ' + cls }).then(function (l) { + if (!l) return; + var rows = l.rows, labels = R.validators.map(function (v) { return vshort(v.validator); }); + rows.forEach(function (x) { if (labels.indexOf(x[3]) < 0) labels.push(x[3]); }); + var byVal = labels.map(function (lab) { return rows.filter(function (x) { return x[3] === lab; }).length; }); + var max = Math.max.apply(null, byVal.concat([1])); + var late = rows.filter(function (x) { return x[5].indexOf('после') >= 0; }).length; + var pass = rows.filter(function (x) { return x[4] === 'pass'; }).length; + var grid = 'grid-template-columns:repeat(' + labels.length + ',minmax(0,1fr))'; + var dist = '

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

' + + '' + + '
' + labels.map(function (lab) { return '' + esc(lab.replace(/^v/, '')) + ''; }).join('') + '
' + + '
всего ' + fmt(rows.length) + 'вердикт pass: ' + fmt(pass) + 'вердикт partial: ' + fmt(rows.length - pass) + 'после вердикта: ' + fmt(late) + 'в вердикте: ' + fmt(rows.length - late) + '
'; + fillDialog({ + title: 'Ingress: ' + cls, + note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.', + cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls, + hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT }, + cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal } + }); + }); + } + + function copyCsv() { + var msg = $('an-dlg-msg'); + if (!cur) return; + var text = csvText(cur.cols, cur.rows); + var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; }; + var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail(); + } + + function downloadCsv() { + if (!cur) return; + window.location.href = listURL(M.csv_url, cur.kind, cur.cls); + } + + /* ---- wiring ---- */ + function renderAll() { + renderSubnets(); + renderTypes(); + renderTargets(); + } + + $('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' + + (M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : ''); + renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll(); + + $('an-kpis').addEventListener('click', function (e) { var b = e.target.closest('[data-list]'); if (b) openIndicator(b.dataset.list); }); + $('an-errs').addEventListener('click', function (e) { var b = e.target.closest('[data-cls]'); if (b) openError(b.dataset.cls); }); + document.querySelectorAll('[data-sort]').forEach(function (b) { + b.addEventListener('click', function () { + state.sort = b.dataset.sort; + document.querySelectorAll('[data-sort]').forEach(function (x) { x.setAttribute('aria-pressed', x === b); }); + renderSubnets(); + }); + }); + $('an-types').addEventListener('click', function (e) { + var b = e.target.closest('[data-type]'); + if (!b) return; + state.type = b.dataset.type; + renderTypes(); renderTargets(); + }); + $('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); }); + $('an-dlg-csv').addEventListener('click', downloadCsv); + $('an-dlg-copy').addEventListener('click', copyCsv); + $('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); }); + $('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); }); + + /* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */ + var tip = $('an-tip'); + function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; } + document.addEventListener('mouseover', function (e) { + var t = e.target.closest('[data-tip]'); + if (!t) return; + var h = tipHost(); + if (tip.parentNode !== h) h.appendChild(tip); + tip.textContent = t.dataset.tip; + tip.style.opacity = 1; + }); + document.addEventListener('mousemove', function (e) { + tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px'; + tip.style.top = (e.clientY + 16) + 'px'; + }); + document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; }); +})(); diff --git a/internal/dashboard/static/dashboard.css b/internal/dashboard/static/dashboard.css index e3d9166..cbe40c7 100644 --- a/internal/dashboard/static/dashboard.css +++ b/internal/dashboard/static/dashboard.css @@ -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 { diff --git a/internal/dashboard/templates/analytics.html b/internal/dashboard/templates/analytics.html new file mode 100644 index 0000000..b9fb189 --- /dev/null +++ b/internal/dashboard/templates/analytics.html @@ -0,0 +1,136 @@ +{{define "analytics_page"}} + + +{{template "html_head" .}} + + + +
+ +
+{{template "sidebar_nav" .}} +
+{{template "topbar_mobile" .}} +
+
{{template "banner_inner" .Banner}}
+{{template "analytics_content" .}} +
+
+
+{{if .HasRun}} + + +{{end}} + + +{{end}} + +{{define "analytics_content"}} +
+
+

Аналитика

+

Один выбранный запуск. Данные других запусков на странице не участвуют.

+
+ +{{if not .Runs}} +
+

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

+
+{{else}} +
+
+ +{{if .PrevURL}}◀{{else}}◀{{end}} + +{{if .NextURL}}▶{{else}}▶{{end}} +
+{{if .HasRun}}

{{else}}

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

{{end}} +
+{{end}} + +{{if .HasRun}} +
+ +
+
+

Почему partial

+
+

Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.

+
+
+

Качество данных

+
+

Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.

+
+
+ +
+
+

Подсети

+
+
+ + +
+ +
+
+
+

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

+
+ +
+
+

Egress по целям

+
+
+
+
+

Подсеть × цель

+
+
+
+

+
+ +
+
+

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

+
+
+
+

Классы ошибок ingress

+
+
+
+ +
+

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

+ +
+

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

+
+ + +
+

+

+ +
+
+ +
+ + + +
+
+
+
+ +{{end}} +
+{{end}} diff --git a/internal/dashboard/templates/layout.html b/internal/dashboard/templates/layout.html index db63dba..dbff02b 100644 --- a/internal/dashboard/templates/layout.html +++ b/internal/dashboard/templates/layout.html @@ -36,14 +36,29 @@ {{define "sidebar_nav"}}