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

Runs (migration 0011): a run groups the cycles of one launch. It opens when an
address enters an idle queue, takes everything submitted or re-checked while it
is open and is finalized when all its addresses are done; a re-check after that
opens a new run, so results of different runs never mix. check_runs,
run_results (one result per address and run, with the verdict and the expected
and stored check counts), subnets, run_id on ip_queue and checks. Existing data
is split into runs at pauses of more than an hour; ingress checks get the
validator that held the address (also at write time from now on).

Analytics (internal/analytics): figures computed from the stored checks of the
latest cycle of each address in the run, as facts next to the verdict: summary,
reasons of partial, data quality, subnets, targets and the subnet x target
matrix by check type, ingress by site, error classes, validators, and the
address lists behind the indicators and error classes. API: analytics runs,
report, lists (JSON or CSV), subnet list; run and subnet filters for the
registry.

Dashboard: /analytics matching the approved mockup (run selector, indicators
with address lists and CSV, error-class dialogs, drill-down to the registry),
subnet list on /settings. Sidebar: the control-api link state, theme toggle and
logout moved to the top, the three dots next to the logo removed, sections
grouped.

Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the
updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-03 18:36:03 +03:00
1 parent 864208238f
commit b7669c9e41
44 files changed
+4122 -61

No files matched your search

+8 -1
View File
@@ -101,6 +101,10 @@ docs/ документация и планы доработок
- Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками. - Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками.
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам. - Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
**Запуски и аналитика**
- Запуск — одна партия проверок: открывается, когда адрес попадает в пустую или обработанную очередь, принимает всё, что добавлено или перепроверено, пока открыт, и завершается, когда у всех его адресов есть итог. Перепроверка после завершения открывает новый запуск; результаты запусков не пересекаются.
- Страница «Аналитика» показывает один завершённый запуск по фактическим проверкам: показатели, причины `partial`, подсети (список — в настройках), цели и типы проверок, площадки, классы ошибок, валидаторы; списки адресов выгружаются в CSV.
**Реестр** **Реестр**
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление. - Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся. - История проверок хранится по циклам; `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 /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` | | Пробер | `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/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` | | Автоцикл | `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` | | Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
| Служебное | `GET /admin/validators`, `GET /healthz` | | Служебное | `GET /admin/validators`, `GET /healthz` |
@@ -162,6 +167,7 @@ docs/ документация и планы доработок
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов | | `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса | | `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) | | `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) |
| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска; списки адресов с выгрузкой в CSV |
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок | | `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок | | `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок |
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается. - Порядок блоков на `/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 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`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-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#управление-валидаторами) | | 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#управление-валидаторами) |
+2 -2
View File
@@ -1,4 +1,4 @@
657d24d49686f04d3e9d9e431bb56ba8d7e2b3f6d865059de8315eceee6df53a control-api 7a5227edddb89763f8db8ea56be5a1703626998498c3969b69272bfdb9d670b9 control-api
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent 9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober 3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
8c636afb4f68b461376a1f5a010faa72cc0b0f17adf55611e9d924936d6fe115 admin-dashboard 1e5c0c3e2857aa2e856d89a6d72db7f402179b9d912177040c6e1fc9217b86d6 admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -3
View File
@@ -12,8 +12,8 @@
| Группа | Таблицы | Можно чистить | | Группа | Таблицы | Можно чистить |
|---|---|---| |---|---|---|
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `events` (журнал событий) | да | | Данные прогона | `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` | **нет** | | Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`, `subnets` (список подсетей для аналитики) | **нет** |
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять | | Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`; Связи (внешние ключи): `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'; UPDATE validators SET state = 'idle' WHERE state = 'assigned';
-- порядок важен: сначала зависимые таблицы -- порядок важен: сначала зависимые таблицы
DELETE FROM ip_site_checks; DELETE FROM ip_site_checks;
DELETE FROM run_results;
DELETE FROM check_runs;
DELETE FROM checks; DELETE FROM checks;
DELETE FROM events; DELETE FROM events;
DELETE FROM ip_queue; DELETE FROM ip_queue;
DELETE FROM ip_registry; DELETE FROM ip_registry;
-- нумерация снова с 1 (необязательно) -- нумерация снова с 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; 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); 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); 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 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 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 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); DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
+68
View File
@@ -705,6 +705,10 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из
`total`». `total`».
Фильтры постраничного режима (только вместе с `limit`): `run` — только адреса,
у которых есть результат в этом запуске (см. [«Аналитика запусков»](#аналитика-запусков)); `subnet` — только
адреса внутри подсети (CIDR, например `203.0.113.0/24`). Неверный `run` или `subnet` — `400`.
### `GET /api/v1/admin/registry/{ip}` ### `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) Для полностью автоматизированного локального прогона (без ручных curl)
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md). см. `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`,
список остаётся прежним. Адрес относится к самой узкой подходящей подсети.
+7 -1
View File
@@ -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`. | | `/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}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). | | `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). | | `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). |
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. | | `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). |
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). | | `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
@@ -179,7 +185,7 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
относительный путь на этом же сайте). относительный путь на этом же сайте).
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет — - **Сессия** хранится в 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` и перезапустить дашборд. скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер - **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента. уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
+37
View File
@@ -24,6 +24,7 @@
- [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail) - [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail)
- [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу) - [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу)
- [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории) - [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории)
- [Аналитика запусков](#аналитика-запусков)
- [Управление валидаторами](#управление-валидаторами) - [Управление валидаторами](#управление-валидаторами)
- [Управление площадками (проберами)](#управление-площадками-проберами) - [Управление площадками (проберами)](#управление-площадками-проберами)
- [Управление типами проверок пробера](#управление-типами-проверок-пробера) - [Управление типами проверок пробера](#управление-типами-проверок-пробера)
@@ -419,6 +420,42 @@ curl -s http://<control-api>: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://<control-api>:8080/api/v1/admin/analytics/runs | python3 -m json.tool
curl -s http://<control-api>:8080/api/v1/admin/analytics/runs/1 | python3 -m json.tool
curl -s -o egress.csv "http://<control-api>:8080/api/v1/admin/analytics/runs/1/lists/egress_https_all?format=csv"
```
Подробности и состав ответов — в [API.md](API.md#аналитика-запусков).
## Управление валидаторами ## Управление валидаторами
Список валидаторов и их текущее состояние: Список валидаторов и их текущее состояние:
@@ -139,3 +139,11 @@
## 10. Порядок по правилам проекта ## 10. Порядок по правилам проекта
План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа. План (этот файл) → реализация по шагам из п.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, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок, Настройки); нижний блок сайдбара убран.
@@ -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.
- Подсказки при наведении не работают на сенсорных экранах и с клавиатуры (как и в макете).
- В сайдбаре на узком экране (меню-«бургер») новый блок сессии я не просматривал отдельно.
- Фильтр реестра по подсети ограничен адресами, попавшими в список подсетей: строка «прочие» без ссылки.
+664
View File
@@ -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 "прочее"
}
+229
View File
@@ -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)
}
}
}
+126
View File
@@ -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
}
+39
View File
@@ -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) },
})
}
+109
View File
@@ -244,6 +244,8 @@ func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
type registryQuery struct { type registryQuery struct {
Q string Q string
LastResult string LastResult string
Run int64 // only addresses with a result in this run
Subnet string // only addresses inside this CIDR
Limit int Limit int
Offset int Offset int
} }
@@ -264,6 +266,12 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
if q.LastResult != "" { if q.LastResult != "" {
v.Set("last_result", 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 var out registryPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out) err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
return out, err 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) err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
return out, err 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
}
+78
View File
@@ -37,6 +37,14 @@ type fakeControlAPI struct {
inboundICMP bool inboundICMP bool
historyRetentionCycles int 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 // scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
// directly rather than this fake reimplementing OpenStack floating-IP // directly rather than this fake reimplementing OpenStack floating-IP
// filtering (already covered by internal/orchestrator's own tests). // 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]}) 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) { mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+254
View File
@@ -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)
}
@@ -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)
}
}
+21 -1
View File
@@ -2,7 +2,9 @@ package dashboard
import ( import (
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"strconv"
"strings" "strings"
) )
@@ -11,6 +13,8 @@ type registryPageData struct {
Items []registryItem Items []registryItem
Query string Query string
StatusFilter string StatusFilter string
Run int64 // drill-down from the analytics page
Subnet string
Page, PerPage int Page, PerPage int
Total int Total int
Pager pagerData Pager pagerData
@@ -37,7 +41,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
} }
perPage := parsePerPage(r.URL.Query().Get("per_page")) perPage := parsePerPage(r.URL.Query().Get("per_page"))
page := parsePage(r.URL.Query().Get("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) res, err := s.CA.ListRegistryPage(r.Context(), query)
if err == nil { if err == nil {
@@ -54,7 +66,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
if status != "" { if status != "" {
params.Set("status", status) params.Set("status", status)
} }
if run > 0 {
params.Set("run", strconv.FormatInt(run, 10))
}
if subnet != "" {
params.Set("subnet", subnet)
}
data := registryPageData{ data := registryPageData{
Run: run,
Subnet: subnet,
Items: res.Items, Items: res.Items,
Query: q, Query: q,
StatusFilter: status, StatusFilter: status,
+32 -2
View File
@@ -14,6 +14,8 @@ type settingsPageData struct {
Inbound inboundChecksDTO Inbound inboundChecksDTO
// AutoCycle is the automatic-check-cycle panel's status/parameters. // AutoCycle is the automatic-check-cycle panel's status/parameters.
AutoCycle autoCycleDTO AutoCycle autoCycleDTO
// Subnets is the list the analytics page groups addresses by.
Subnets subnetList
} }
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { 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 { if err == nil {
err = autoCycleErr 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.ActiveNav = "settings"
data.Banner = bannerFor(err) data.Banner = bannerFor(err)
s.renderPage(w, r, "settings_page", data) 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 { if actionErr == nil {
actionErr = autoCycleErr 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) { 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) 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 // parseMinutes converts a form field holding a (possibly fractional) number
// of minutes into whole seconds. // of minutes into whole seconds.
func parseMinutes(raw string) (int, error) { func parseMinutes(raw string) (int, error) {
+5
View File
@@ -29,6 +29,10 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /registry", s.handleRegistryPage) mux.HandleFunc("GET /registry", s.handleRegistryPage)
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail) 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("GET /validators", s.handleValidatorsPage)
mux.HandleFunc("POST /validators", s.handleValidatorCreate) mux.HandleFunc("POST /validators", s.handleValidatorCreate)
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate) 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("GET /settings", s.handleSettingsPage)
mux.HandleFunc("PUT /settings", s.handleSettingsPut) mux.HandleFunc("PUT /settings", s.handleSettingsPut)
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut) 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("PUT /settings/auto-cycle", s.handleAutoCyclePut)
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart) mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop) mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
+122
View File
@@ -0,0 +1,122 @@
/* Analytics page. Everything is prefixed an-; the dashboard's own tokens
(--surface, --border, --accent, ...) are used as they are. */
:root {
--an-radius: 4px;
--an-shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
--an-bar: #3987e5; --an-bar-track: #E4E9F0;
/* heat ramp: one blue hue, validated sequential steps (light = low) */
--an-h1:#cde2fb; --an-h2:#9ec5f4; --an-h3:#6da7ec; --an-h4:#3987e5; --an-h5:#256abf; --an-h6:#184f95; --an-h7:#0d366b;
--an-hi1:#12161F; --an-hi2:#12161F; --an-hi3:#12161F; --an-hi4:#FFFFFF; --an-hi5:#FFFFFF; --an-hi6:#FFFFFF; --an-hi7:#FFFFFF;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
}
}
:root[data-theme="dark"] {
--an-shadow: 0 1px 2px rgba(0,0,0,.4);
--an-bar: #5C8CFF; --an-bar-track: #1D2430;
--an-h1:#0d366b; --an-h2:#104281; --an-h3:#184f95; --an-h4:#256abf; --an-h5:#3987e5; --an-h6:#6da7ec; --an-h7:#9ec5f4;
--an-hi1:#B8C9E8; --an-hi2:#C3D6F5; --an-hi3:#E7ECF5; --an-hi4:#FFFFFF; --an-hi5:#0A0D13; --an-hi6:#0A0D13; --an-hi7:#0A0D13;
}
.an { display: grid; gap: 16px; align-content: start; min-width: 0; font-family: var(--font-ui); font-size: 14px; line-height: 1.5; }
.an * { box-sizing: border-box; }
.an-title { font: 700 18px var(--font-mono); margin: 0; }
.an h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
.an-sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
.an-panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); box-shadow: var(--an-shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
.an-panel > * { min-width: 0; }
.an-head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
.an-runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.an-runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
.an select, .an .an-btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 7px 10px; }
.an select { min-width: 0; max-width: 100%; flex: 1 1 160px; }
.an-btn { cursor: pointer; } .an-btn:hover { background: var(--surface-alt); }
.an-tabs { display: inline-flex; gap: 0; }
.an-tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
.an-tabs button + button { border-left: 0; }
.an-tabs button:first-child { border-radius: var(--an-radius) 0 0 var(--an-radius); } .an-tabs button:last-child { border-radius: 0 var(--an-radius) var(--an-radius) 0; }
.an-tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
.an-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; }
.an-kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--an-radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--an-shadow); }
.an-kpi .an-k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
.an-kpi .an-v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.an-kpi .an-d { font-size: 12px; color: var(--text-muted); }
.an-tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
.an-tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
.an-t-ok { background: var(--success-soft); color: var(--success); } .an-t-warn { background: var(--warning-soft); color: var(--warning); } .an-t-bad { background: var(--danger-soft); color: var(--danger); } .an-t-n { background: var(--neutral-soft); color: var(--neutral); }
.an-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
.an-rows { display: grid; gap: 7px; }
.an-bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
.an-bar-row .an-n { overflow-wrap: anywhere; }
.an-track { height: 12px; background: var(--an-bar-track); border-radius: 2px; position: relative; }
.an-fill { height: 100%; background: var(--an-bar); border-radius: 0 4px 4px 0; }
.an-num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.an-scroll { overflow-x: auto; }
.an table { border-collapse: collapse; width: 100%; font-size: 13px; }
.an th .an-hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
.an th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an th.an-r, .an td.an-r { text-align: right; }
.an td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
.an td.an-a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
.an td.an-a a { color: var(--accent-strong); text-decoration: none; } td.an-a a:hover { text-decoration: underline; }
.an-pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
.an-pb .an-track { height: 8px; }
.an-heat td.an-c { padding: 2px; }
.an-cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
.an-h1{background:var(--an-h1);color:var(--an-hi1)} .an-h2{background:var(--an-h2);color:var(--an-hi2)} .an-h3{background:var(--an-h3);color:var(--an-hi3)} .an-h4{background:var(--an-h4);color:var(--an-hi4)} .an-h5{background:var(--an-h5);color:var(--an-hi5)} .an-h6{background:var(--an-h6);color:var(--an-hi6)} .an-h7{background:var(--an-h7);color:var(--an-hi7)}
.an-legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
.an-legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
.an-vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
.an-vals .an-col { flex: 1 1 0; min-width: 0; background: var(--an-bar); border-radius: 3px 3px 0 0; }
.an-vals .an-avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
.an-vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
.an-dq { display: grid; gap: 8px; }
.an-dq .an-row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
.an-dq .an-row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
.an-note { font-size: 12px; color: var(--text-muted); }
button.an-kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
button.an-kpi:hover { border-color: var(--accent); }
button.an-kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.an-kpi .an-go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
.an dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
.an dialog[open] { display: flex; flex-direction: column; }
.an dialog::backdrop { background: rgba(10,14,20,.5); }
.an-dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
.an-dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
.an-dlg .an-note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
.an-dlg .an-dist { flex: 0 0 auto; }
.an-dlg .an-list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--an-radius); }
.an-dlg table { font-size: 12.5px; } .an-dlg th { position: sticky; top: 0; background: var(--surface); }
.an-dlg .an-foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
.an-dlg .an-foot .an-acts { display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 520px) { .an-dlg { padding: 12px; gap: 10px; } .an-dlg .an-foot .an-acts { width: 100%; } .an-dlg .an-foot .an-acts .an-btn { flex: 1 1 auto; } }
@media (max-height: 540px) { .an-dlg .an-dist { display: none; } }
@media (max-height: 640px) { .an-dist .an-cols20 { height: 44px; } .an-dlg .an-note { max-height: 3em; } .an-dlg .an-dist .an-chips { display: none; } }
.an-btn.an-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.an-btn.an-primary:hover { background: var(--accent-strong); }
button.an-bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--an-radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
button.an-bar-row:hover { background: var(--surface-alt); }
button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.an-dist { display: grid; gap: 8px; }
.an-dist .an-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.an-dist .an-cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
.an-dist .an-cols20 div { background: var(--an-bar); border-radius: 2px 2px 0 0; min-height: 1px; }
.an-dist .an-lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
#an-tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
@media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
/* dashboard.css turns every table into stacked cards on a phone; the tables on
this page are data tables and scroll sideways instead. */
@media (max-width: 640px) {
.an-kpi .an-v { font-size: 22px; }
.an thead { display: table-header-group; }
.an table { display: table; width: 100%; }
.an tbody { display: table-row-group; width: auto; }
.an tr, .an tbody tr { display: table-row; width: auto; padding: 0; border-bottom: 0; }
.an td { display: table-cell; width: auto; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
}
+347
View File
@@ -0,0 +1,347 @@
/* Analytics page: renders the report of one finished run (embedded as JSON in
#analytics-data) and opens the address lists behind the indicators and the
error classes in a dialog. No other run's data is on the page. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
var state = { sort: 'worst', all: false, type: R.targets.types.indexOf('https') >= 0 ? 'https' : (R.targets.types[0] || '') };
/* ---- indicators ---- */
function renderKpis() {
var late = Q.late_failed_checks_at_pass;
var tiles = [
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>', ''],
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>', ''],
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
S.fail ? '<span class="an-tag an-t-bad">провал</span>' : '<span class="an-tag an-t-n">нет</span>', ''],
['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''],
['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''],
['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна',
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'egress_https_any'],
['Ingress ssh: есть провалы', fmt(S.ingress_ssh_any_failed), pct1(S.ingress_ssh_any_failed, S.addresses) + '% адресов, хотя бы с одной площадки',
'<span class="an-tag an-t-warn">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_any'],
['Egress https: все провалены', fmt(S.egress_https_all_failed), pct1(S.egress_https_all_failed, S.addresses) + '% адресов, по всем целям: ' + fmt(S.egress_https_all_targets_failed),
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'egress_https_all'],
['Ingress ssh: все провалены', fmt(S.ingress_ssh_all_failed), pct1(S.ingress_ssh_all_failed, S.addresses) + '% адресов, ssh провален со всех площадок',
'<span class="an-tag an-t-bad">прикладной</span> <span class="an-go">список →</span>', 'ingress_ssh_all'],
['Длительность', esc(M.duration), fmt(S.addresses_per_minute) + ' адр./мин', '', ''],
['Поздние результаты', fmt(late), 'у ' + fmt(Q.late_failed_addresses_at_pass) + ' адресов, после вердикта',
late ? '<span class="an-tag an-t-warn">внимание</span>' : '<span class="an-tag an-t-ok">в норме</span>', '']
];
$('an-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + t[1] + '</div><div class="an-d">' + t[2] + ' ' + t[3] + '</div>';
return t[4]
? '<button type="button" class="an-kpi" data-list="' + t[4] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
function bar(v, max) {
return '<div class="an-track"><div class="an-fill" style="width:' + (max ? Math.max(1, v / max * 100) : 0) + '%"></div></div>';
}
function renderReasons() {
var max = Math.max.apply(null, R.reasons.map(function (x) { return x.count; }).concat([1]));
$('an-reasons').innerHTML = R.reasons.map(function (x) {
return '<div class="an-bar-row" ' + tipAttr(x.name + ': ' + fmt(x.count) + ' адр. (' + pct(x.count, S.partial) + '% от partial)') + '><span class="an-n">' + esc(x.name) + '</span>' + bar(x.count, max) + '<span class="an-num">' + fmt(x.count) + '</span></div>';
}).join('');
}
function renderQuality() {
var errTotal = Q.ingress_failed_checks;
var rows = [
['Поздние провалы у адресов с вердиктом pass', fmt(Q.late_failed_checks_at_pass) + ' / ' + fmt(Q.late_failed_addresses_at_pass) + ' адр.'],
['Провалы ingress, пришедшие после вердикта', fmt(Q.ingress_failed_late) + ' из ' + fmt(errTotal)],
['Неполный набор проверок', fmt(Q.incomplete_addresses) + ' адр.'],
['Вердикт pass, но есть проваленные проверки', fmt(Q.pass_with_failed_addresses) + ' адр.'],
['pass по вердикту → по фактическим проверкам', fmt(S.pass) + ' → ' + fmt(Q.pass_by_facts)]
];
$('an-dq').innerHTML = rows.map(function (r) { return '<div class="an-row"><span>' + r[0] + '</span><span>' + r[1] + '</span></div>'; }).join('');
}
function pbar(a, n) {
var p = pct(a, n);
return '<div class="an-pb"><div class="an-track"><div class="an-fill" style="width:' + p + '%"></div></div><span class="an-num">' + p + '%</span></div>';
}
function renderSubnets() {
var list = R.subnets.slice();
list.sort(state.sort === 'worst'
? function (a, b) { return a.pass / a.addresses - b.pass / b.addresses || b.addresses - a.addresses; }
: function (a, b) { return b.addresses - a.addresses; });
var shown = state.all ? list : list.slice(0, 10);
$('an-allsub').textContent = state.all ? 'свернуть до 10' : 'показать все ' + list.length;
$('an-allsub').hidden = list.length <= 10;
$('an-subtbl').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">Адресов</th><th class="an-r">pass</th><th class="an-r">Egress OK</th><th class="an-r">Ingress OK</th></tr></thead><tbody>' +
shown.map(function (s) {
var name = s.label ? s.cidr + ' · ' + s.label : s.cidr;
var link = s.cidr === 'прочие' ? esc(name) : '<a href="' + M.registry_url + '&amp;subnet=' + encodeURIComponent(s.cidr) + '" title="Открыть в реестре: запуск ' + M.run_id + ', подсеть ' + esc(s.cidr) + '">' + esc(name) + '</a>';
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.pass, s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': egress OK ' + fmt(s.egress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.egress_ok, s.addresses) + '</td>' +
'<td class="an-r" ' + tipAttr(s.cidr + ': ingress OK ' + fmt(s.ingress_ok) + ' из ' + fmt(s.addresses)) + '>' + pbar(s.ingress_ok, s.addresses) + '</td></tr>';
}).join('') + '</tbody>';
}
function heatClass(v) { return 'an-h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
function renderTypes() {
$('an-types').innerHTML = R.targets.types.map(function (t) {
return '<button type="button" data-type="' + esc(t) + '" aria-pressed="' + (t === state.type) + '">' + esc(t) + '</button>';
}).join('');
}
function renderTargets() {
var t = state.type, T = R.targets, vals = T.failed[t] || [];
var max = Math.max.apply(null, vals.concat([1]));
$('an-targets').innerHTML = T.targets.map(function (n, i) {
return '<div class="an-bar-row" ' + tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], S.addresses) + '% всех)') + '><span class="an-n">' + esc(n) + '</span>' + bar(vals[i], max) + '<span class="an-num">' + fmt(vals[i]) + ' · ' + pct(vals[i], S.addresses) + '%</span></div>';
}).join('');
var rows = (R.matrix[t] || []).slice(0, state.all ? 100000 : 10);
$('an-matrixwrap').hidden = !rows.length;
$('an-hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map(function (l, i) {
return '<span><i style="background:var(--an-h' + (i + 1) + ')"></i> ' + l + '%</span>';
}).join('');
$('an-matrix').innerHTML = '<thead><tr><th>Подсеть</th><th class="an-r">partial</th>' + T.targets.map(function (x) {
return '<th class="an-r">' + esc(x.replace('.com', '').replace('.org', '')) + '</th>';
}).join('') + '</tr></thead><tbody>' + rows.map(function (m) {
var link = m.cidr === 'прочие' ? esc(m.cidr) : '<a href="' + M.registry_url + '&amp;subnet=' + encodeURIComponent(m.cidr) + '" title="Открыть адреса в реестре">' + esc(m.cidr) + '</a>';
return '<tr><td class="an-a">' + link + '</td><td class="an-num an-r">' + fmt(m.partial) + '</td>' + m.percent.map(function (v, j) {
return '<td class="an-c"><span class="an-cell ' + heatClass(v) + '" ' + tipAttr(m.cidr + ' → ' + T.targets[j] + ': ' + v + '% адресов partial провалили ' + t) + '>' + v + '%</span></td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-matrixnote').textContent = 'Доля адресов partial подсети, провалившие ' + t + ' к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
}
function renderSites() {
var T = R.sites;
$('an-sites').innerHTML = '<thead><tr><th>Площадка</th>' + T.types.map(function (t) { return '<th class="an-r">' + esc(t) + '</th>'; }).join('') + '</tr></thead><tbody>' +
T.rows.map(function (row) {
return '<tr><td class="an-a">' + esc(row.site) + '</td>' + row.stats.map(function (st, i) {
return '<td class="an-r" ' + tipAttr(row.site + ' · ' + T.types[i] + ': успешно ' + fmt(st.ok) + ' из ' + fmt(st.total)) + '><span class="an-num">' + pct1(st.total - st.ok, st.total) + '%</span> <span class="an-note">провал</span></td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderErrors() {
var max = Math.max.apply(null, R.errors.map(function (e) { return e.count; }).concat([1]));
$('an-errs').innerHTML = R.errors.length ? R.errors.map(function (e) {
return '<button type="button" class="an-bar-row" data-cls="' + esc(e.name) + '" aria-haspopup="dialog" ' + tipAttr(e.name + ': ' + fmt(e.count) + ' проверок. Нажмите, чтобы открыть список') + '><span class="an-n">' + esc(e.name) + '</span>' + bar(e.count, max) + '<span class="an-num">' + fmt(e.count) + '</span></button>';
}).join('') : '<p class="an-note">Проваленных ingress-проверок нет.</p>';
}
function renderValidators() {
var V = R.validators;
var f = V.map(function (v) { return v.total ? 1 - v.ok / v.total : 0; });
var avg = f.length ? f.reduce(function (a, b) { return a + b; }, 0) / f.length : 0;
$('an-vals').innerHTML = f.map(function (x, i) {
return '<div class="an-col" style="height:' + (x * 100) + '%" ' + tipAttr(vshort(V[i].validator) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(V[i].total) + ' https-проверок') + '></div>';
}).join('') + '<div class="an-avg" style="bottom:' + (avg * 100) + '%"></div>';
$('an-vfirst').textContent = V.length ? 'валидатор ' + vshort(V[0].validator) : '';
$('an-vlast').textContent = V.length > 1 ? 'валидатор ' + vshort(V[V.length - 1].validator) : '';
$('an-vavg').textContent = V.length ? 'среднее ' + Math.round(avg * 100) + '% (шкала 0–100%)' : 'нет данных';
}
/* ---- address lists in the dialog ---- */
var VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
var STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
var VERDICT_VAL = {
pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.',
partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.',
fail: 'fail: все проверки адреса провалены.'
};
function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; }
var LISTS = {
egress_https_any: {
title: 'Egress https: адреса с проваленными проверками',
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.',
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.' }
},
ingress_ssh_any: {
title: 'Ingress ssh: адреса с проваленными проверками',
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.',
hints: { 2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.' }
},
egress_https_all: {
title: 'Egress https: адреса, провалившие все проверки',
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.',
hints: { 2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше полного набора значит, что часть результатов не пришла.' }
},
ingress_ssh_all: {
title: 'Ingress ssh: адреса, провалившие все проверки',
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.',
hints: { 2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.' }
}
};
var cur = null; // the list shown in the dialog
function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
}
function csvText(cols, rows) {
function q(v) { return '"' + String(v).replace(/"/g, '""') + '"'; }
return [cols].concat(rows).map(function (r) { return r.map(q).join(','); }).join('\r\n') + '\r\n';
}
function openDialog() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
function fillDialog(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = 'Запуск: ' + m.runLabel + '. ' + m.note;
$('an-dlg-dist').hidden = !m.dist;
$('an-dlg-dist').innerHTML = m.dist || '';
var hint = m.hints || {};
$('an-dlg-tbl').innerHTML = '<thead><tr>' + m.cols.map(function (c, i) {
return hint[i] ? '<th ' + tipAttr(hint[i]) + ' tabindex="0">' + esc(c) + '<span class="an-hint" aria-hidden="true">ⓘ</span></th>' : '<th>' + esc(c) + '</th>';
}).join('') + '</tr></thead><tbody>' + m.rows.map(function (x) {
return '<tr>' + x.map(function (v, i) {
var h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : '';
return '<td class="' + (i < 2 ? 'an-a' : '') + '"' + (h ? ' ' + tipAttr(h) : '') + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
$('an-dlg-msg').textContent = '';
}
function loadList(kind, cls, meta) {
$('an-dlg-title').textContent = meta.title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
openDialog();
return fetch(listURL(M.list_url, kind, cls), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (l) { return l; })
.catch(function (e) {
$('an-dlg-note').textContent = 'Не удалось загрузить список: ' + e.message;
return null;
});
}
function runLabel() {
var o = document.getElementById('an-run');
return o && o.selectedOptions[0] ? o.selectedOptions[0].textContent : 'запуск ' + M.run_id;
}
function openIndicator(kind) {
var meta = LISTS[kind];
loadList(kind, '', meta).then(function (l) {
if (!l) return;
fillDialog({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), file: kind, kind: kind, cls: '' });
});
}
function openError(cls) {
loadList('error', cls, { title: 'Ingress: ' + cls }).then(function (l) {
if (!l) return;
var rows = l.rows, labels = R.validators.map(function (v) { return vshort(v.validator); });
rows.forEach(function (x) { if (labels.indexOf(x[3]) < 0) labels.push(x[3]); });
var byVal = labels.map(function (lab) { return rows.filter(function (x) { return x[3] === lab; }).length; });
var max = Math.max.apply(null, byVal.concat([1]));
var late = rows.filter(function (x) { return x[5].indexOf('после') >= 0; }).length;
var pass = rows.filter(function (x) { return x[4] === 'pass'; }).length;
var grid = 'grid-template-columns:repeat(' + labels.length + ',minmax(0,1fr))';
var dist = '<h2>Распределение по валидаторам</h2>' +
'<div class="an-cols20" style="' + grid + '" role="img" aria-label="Число проваленных проверок по валидаторам">' + byVal.map(function (v, i) {
return '<div style="height:' + Math.max(1, v / max * 100) + '%" ' + tipAttr('валидатор ' + labels[i] + ': ' + v + ' проверок') + '></div>';
}).join('') + '</div>' +
'<div class="an-lab20" style="' + grid + '">' + labels.map(function (lab) { return '<span>' + esc(lab.replace(/^v/, '')) + '</span>'; }).join('') + '</div>' +
'<div class="an-chips"><span class="an-tag an-t-n">всего ' + fmt(rows.length) + '</span><span class="an-tag an-t-ok">вердикт pass: ' + fmt(pass) + '</span><span class="an-tag an-t-warn">вердикт partial: ' + fmt(rows.length - pass) + '</span><span class="an-tag an-t-bad">после вердикта: ' + fmt(late) + '</span><span class="an-tag an-t-n">в вердикте: ' + fmt(rows.length - late) + '</span></div>';
fillDialog({
title: 'Ingress: ' + cls,
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls,
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal }
});
});
}
function copyCsv() {
var msg = $('an-dlg-msg');
if (!cur) return;
var text = csvText(cur.cols, cur.rows);
var done = function () { msg.textContent = 'CSV скопирован в буфер обмена.'; };
var fail = function () { msg.textContent = 'Буфер обмена недоступен в этом окне.'; };
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail();
}
function downloadCsv() {
if (!cur) return;
window.location.href = listURL(M.csv_url, cur.kind, cur.cls);
}
/* ---- wiring ---- */
function renderAll() {
renderSubnets();
renderTypes();
renderTargets();
}
$('an-runnote').textContent = 'Тип: ' + M.kind + '. Начало ' + M.start + ', завершён ' + M.end + ', длительность ' + M.duration + '.' +
(M.rechecked ? ' Перепроверено внутри запуска: ' + M.rechecked + ' адр. (берётся последний цикл).' : '');
renderKpis(); renderReasons(); renderQuality(); renderErrors(); renderSites(); renderValidators(); renderAll();
$('an-kpis').addEventListener('click', function (e) { var b = e.target.closest('[data-list]'); if (b) openIndicator(b.dataset.list); });
$('an-errs').addEventListener('click', function (e) { var b = e.target.closest('[data-cls]'); if (b) openError(b.dataset.cls); });
document.querySelectorAll('[data-sort]').forEach(function (b) {
b.addEventListener('click', function () {
state.sort = b.dataset.sort;
document.querySelectorAll('[data-sort]').forEach(function (x) { x.setAttribute('aria-pressed', x === b); });
renderSubnets();
});
});
$('an-types').addEventListener('click', function (e) {
var b = e.target.closest('[data-type]');
if (!b) return;
state.type = b.dataset.type;
renderTypes(); renderTargets();
});
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); renderTargets(); });
$('an-dlg-csv').addEventListener('click', downloadCsv);
$('an-dlg-copy').addEventListener('click', copyCsv);
$('an-dlg-close').addEventListener('click', function () { $('an-dlg').close(); });
$('an-dlg').addEventListener('click', function (e) { if (e.target === $('an-dlg')) $('an-dlg').close(); });
/* tooltip: moved into the open dialog, otherwise the modal's top layer covers it */
var tip = $('an-tip');
function tipHost() { var d = $('an-dlg'); return d && d.open ? d : document.body; }
document.addEventListener('mouseover', function (e) {
var t = e.target.closest('[data-tip]');
if (!t) return;
var h = tipHost();
if (tip.parentNode !== h) h.appendChild(tip);
tip.textContent = t.dataset.tip;
tip.style.opacity = 1;
});
document.addEventListener('mousemove', function (e) {
tip.style.left = Math.max(8, Math.min(e.clientX + 14, window.innerWidth - 376)) + 'px';
tip.style.top = (e.clientY + 16) + 'px';
});
document.addEventListener('mouseout', function (e) { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
})();
+15 -14
View File
@@ -180,11 +180,9 @@ main > h2.section-title:first-child { margin-top: 0; }
padding: 18px 12px; padding: 18px 12px;
display: flex; display: flex;
flex-direction: column; 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 { 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 { .brand-mark {
width: 26px; height: 26px; width: 26px; height: 26px;
border-radius: var(--radius-xs); 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.active svg { opacity: 1; }
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.sidebar-foot { /* Session block at the top of the sidebar: link state to control-api, theme
margin-top: auto; toggle and, with login enabled, who is signed in and the logout button. */
.sidebar-session {
display: grid; gap: 8px;
padding: 9px 10px; padding: 9px 10px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-alt); background: var(--surface-alt);
@@ -227,9 +227,16 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
font-family: var(--font-display); font-family: var(--font-display);
font-size: 11px; font-size: 11px;
color: var(--text-muted); 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 { .pulse-dot {
width: 6px; height: 6px; border-radius: 1px; width: 6px; height: 6px; border-radius: 1px;
background: var(--success); 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; animation: pulse 2.2s ease-in-out infinite;
} }
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } } @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; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.theme-toggle { .theme-toggle {
@@ -568,13 +576,6 @@ textarea.autosize { overflow-y: hidden; resize: none; }
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; } .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. */ /* .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; } .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 ---------- */ /* ---------- scan progress, progress bars, pager, bulk selection ---------- */
progress { progress {
+136
View File
@@ -0,0 +1,136 @@
{{define "analytics_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}
<link rel="stylesheet" href="/static/analytics.css">
</head>
<body>
<div class="bg-grid"></div>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "analytics_content" .}}
</main>
</div>
</div>
{{if .HasRun}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics.js"></script>
{{end}}
</body>
</html>
{{end}}
{{define "analytics_content"}}
<div class="an">
<div class="an-head">
<h1 class="an-title">Аналитика</h1>
<p class="an-sub">Один выбранный запуск. Данные других запусков на странице не участвуют.</p>
</div>
{{if not .Runs}}
<section class="an-panel">
<p class="an-note">Запусков проверки пока нет. Они появляются, когда адреса ставятся в очередь на странице <a href="/ips">«Очередь IP»</a> или запускается автоматический цикл.</p>
</section>
{{else}}
<section class="an-panel" aria-label="Выбор запуска">
<div class="an-runbar">
<label for="an-run">Запуск</label>
{{if .PrevURL}}<a class="an-btn" href="{{.PrevURL}}" aria-label="Предыдущий запуск">◀</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">◀</span>{{end}}
<select id="an-run" onchange="if (this.value) location.href = '/analytics?run=' + encodeURIComponent(this.value)">
{{range .Runs}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}{{if .Disabled}} disabled{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if .NextURL}}<a class="an-btn" href="{{.NextURL}}" aria-label="Следующий запуск">▶</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">▶</span>{{end}}
</div>
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
</section>
{{end}}
{{if .HasRun}}
<div class="an-kpis" id="an-kpis"></div>
<div class="an-cols">
<section class="an-panel" aria-labelledby="an-h-reasons">
<h2 id="an-h-reasons">Почему partial</h2>
<div class="an-rows" id="an-reasons"></div>
<p class="an-note">Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.</p>
</section>
<section class="an-panel" aria-labelledby="an-h-dq">
<h2 id="an-h-dq">Качество данных</h2>
<div class="an-dq" id="an-dq"></div>
<p class="an-note">Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.</p>
</section>
</div>
<section class="an-panel" aria-labelledby="an-h-sub">
<div class="an-head">
<h2 id="an-h-sub">Подсети</h2>
<div class="an-runbar">
<div class="an-tabs" role="group" aria-label="Сортировка подсетей">
<button type="button" data-sort="worst" aria-pressed="true">хуже всего</button>
<button type="button" data-sort="size" aria-pressed="false">больше адресов</button>
</div>
<button class="an-btn" id="an-allsub" type="button"></button>
</div>
</div>
<div class="an-scroll"><table id="an-subtbl"></table></div>
<p class="an-note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
</section>
<section class="an-panel" aria-labelledby="an-h-eg">
<div class="an-head">
<h2 id="an-h-eg">Egress по целям</h2>
<div class="an-tabs" role="group" aria-label="Тип проверки" id="an-types"></div>
</div>
<div class="an-rows" id="an-targets"></div>
<div id="an-matrixwrap">
<div class="an-head"><h2 style="margin-top:6px">Подсеть × цель</h2>
<div class="an-legend" id="an-hlegend"></div></div>
<div class="an-scroll"><table class="an-heat" id="an-matrix"></table></div>
</div>
<p class="an-note" id="an-matrixnote"></p>
</section>
<div class="an-cols">
<section class="an-panel" aria-labelledby="an-h-in">
<h2 id="an-h-in">Ingress по площадкам</h2>
<div class="an-scroll"><table id="an-sites"></table></div>
</section>
<section class="an-panel" aria-labelledby="an-h-err">
<h2 id="an-h-err">Классы ошибок ingress</h2>
<div class="an-rows" id="an-errs"></div>
</section>
</div>
<section class="an-panel" aria-labelledby="an-h-val">
<h2 id="an-h-val">Валидаторы: доля провалов egress https</h2>
<div class="an-vals" id="an-vals" role="img" aria-label="Доля проваленных https-проверок по валидаторам"></div>
<div class="an-vlab"><span id="an-vfirst"></span><span id="an-vavg"></span><span id="an-vlast"></span></div>
<p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
</section>
<dialog id="an-dlg" aria-labelledby="an-dlg-title">
<div class="an-dlg">
<h3 id="an-dlg-title"></h3>
<p class="an-note" id="an-dlg-note"></p>
<div class="an-dist" id="an-dlg-dist" hidden></div>
<div class="an-list"><table id="an-dlg-tbl"></table></div>
<div class="an-foot">
<span class="an-note" id="an-dlg-msg" role="status"></span>
<div class="an-acts">
<button class="an-btn" type="button" id="an-dlg-copy">Копировать</button>
<button class="an-btn an-primary" type="button" id="an-dlg-csv">Скачать CSV</button>
<button class="an-btn" type="button" id="an-dlg-close" autofocus>Закрыть</button>
</div>
</div>
</div>
</dialog>
<div id="an-tip" role="tooltip"></div>
{{end}}
</div>
{{end}}
+22 -16
View File
@@ -36,14 +36,29 @@
{{define "sidebar_nav"}} {{define "sidebar_nav"}}
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<span class="brand-chrome"><i></i><i></i><i></i></span>
<span class="brand-mark"> <span class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg>
</span> </span>
<span class="brand-name">Cloud IP Validator<small>admin</small></span> <span class="brand-name">Cloud IP Validator<small>admin</small></span>
</div> </div>
<nav class="nav-groups"> <div class="sidebar-session">
{{$down := and .Banner.Message (not .Banner.Client)}}
<div class="session-row">
<span class="session-api" title="{{if $down}}Нет связи с control-api: {{.Banner.Message}}{{else}}Связь с control-api в порядке{{end}}"><span class="pulse-dot{{if $down}} down{{end}}"></span>control-api{{if $down}} · нет связи{{end}}</span>
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
</button>
</div>
{{if .AuthEnabled}}<form class="session-row" method="post" action="/logout">
<span class="session-user" title="{{.User}}">{{.User}}</span>
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
</form>{{end}}
</div>
<nav class="nav-groups" aria-label="Разделы">
<div class="nav-group-title">Мониторинг</div>
<a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview"> <a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
Обзор Обзор
@@ -56,6 +71,11 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg>
Реестр Реестр
</a> </a>
<a class="nav-link{{if eq .ActiveNav "analytics"}} active{{end}}" {{if eq .ActiveNav "analytics"}}aria-current="page"{{end}} href="/analytics">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
Аналитика
</a>
<div class="nav-group-title">Настройка</div>
<a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators"> <a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg>
Валидаторы Валидаторы
@@ -78,20 +98,6 @@
</a> </a>
</nav> </nav>
{{if .AuthEnabled}}
<form class="sidebar-user" method="post" action="/logout">
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
</form>
{{end}}
<div class="sidebar-foot">
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
</button>
</div>
</aside> </aside>
<script> <script>
(function () { (function () {
@@ -26,6 +26,13 @@
Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p> Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p>
<form id="registry-filter" class="panel" onsubmit="return false" style="margin-bottom:16px"> <form id="registry-filter" class="panel" onsubmit="return false" style="margin-bottom:16px">
{{if .Run}}<input type="hidden" name="run" value="{{.Run}}">{{end}}
{{if .Subnet}}<input type="hidden" name="subnet" value="{{.Subnet}}">{{end}}
{{if or .Run .Subnet}}<div class="panel-body" style="padding-bottom:0">
<span class="pill pill-info">Из аналитики:{{if .Run}} запуск {{.Run}}{{end}}{{if .Subnet}} · подсеть {{.Subnet}}{{end}}</span>
<a href="/registry" style="margin-left:10px">сбросить фильтр</a>
{{if .Run}}<a href="/analytics?run={{.Run}}" style="margin-left:10px">к аналитике</a>{{end}}
</div>{{end}}
<div class="panel-body field-row"> <div class="panel-body field-row">
<div class="field" style="flex:1 1 260px"> <div class="field" style="flex:1 1 260px">
<label for="registry-q">Поиск по IP</label> <label for="registry-q">Поиск по IP</label>
@@ -117,4 +117,23 @@ inbound-проверки к нулю, не трогая список площа
</form> </form>
</div> </div>
</div> </div>
<div class="panel">
<div class="panel-body">
<p class="muted" style="margin-bottom:16px">Подсети для страницы <a href="/analytics">«Аналитика»</a>: по ним
группируются адреса запуска. По одной в строке — <code>CIDR</code> и, через пробел, необязательная подпись
(<code>83.166.248.0/21 москва</code>). Адрес относится к самой узкой подходящей подсети, остальные идут в строку
«прочие». Пока список пуст, адреса группируются по /24.</p>
<form hx-put="/settings/subnets" hx-target="#settings-form-wrap" hx-swap="innerHTML">
<div class="field">
<label for="subnets_text">Подсети</label>
<textarea id="subnets_text" name="subnets" rows="8" spellcheck="false" placeholder="83.166.248.0/21&#10;212.233.72.0/21 подпись">{{range .Subnets.Subnets}}{{.CIDR}}{{if .Label}} {{.Label}}{{end}}
{{end}}</textarea>
</div>
<div class="field-row">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
{{end}} {{end}}
+8
View File
@@ -43,6 +43,9 @@ var scaleIndexesSchema string
//go:embed migrations/0010_verdict_integrity.sql //go:embed migrations/0010_verdict_integrity.sql
var verdictIntegritySchema string var verdictIntegritySchema string
//go:embed migrations/0011_check_runs.sql
var checkRunsSchema string
// migrations is the ordered list of schema versions. Each entry's SQL is // migrations is the ordered list of schema versions. Each entry's SQL is
// applied, in order, for any version greater than the database's current // applied, in order, for any version greater than the database's current
// PRAGMA user_version — so a fresh database walks the whole list and an // PRAGMA user_version — so a fresh database walks the whole list and an
@@ -61,6 +64,7 @@ var migrations = []struct {
{8, autoCycleSchema}, {8, autoCycleSchema},
{9, scaleIndexesSchema}, {9, scaleIndexesSchema},
{10, verdictIntegritySchema}, {10, verdictIntegritySchema},
{11, checkRunsSchema},
} }
type DB struct { type DB struct {
@@ -96,6 +100,10 @@ func Open(ctx context.Context, path string) (*DB, error) {
sqlDB.Close() sqlDB.Close()
return nil, fmt.Errorf("migrate: %w", err) return nil, fmt.Errorf("migrate: %w", err)
} }
if err := d.adoptOrphanQueueRows(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("adopt queue rows into a run: %w", err)
}
return d, nil return d, nil
} }
@@ -0,0 +1,98 @@
-- Check runs (see docs/changes/2026-10-03_16-39_analytics-section-plan.md).
--
-- cycle_id counts per address, so it cannot tell one launch from another. A
-- run groups the cycles of one launch: it opens when an address enters an idle
-- queue, takes every address submitted or re-checked while it is open, and is
-- finalized when all its queue rows are terminal. checks.run_id and
-- ip_queue.run_id carry the membership; run_results keeps one row per address
-- and run (its latest cycle) with the verdict, which ip_queue overwrites on a
-- re-check.
--
-- No foreign keys on purpose: the manual cleanup in docs/ADMIN_CLEANUP.md
-- deletes from these tables freely.
CREATE TABLE check_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL DEFAULT 'manual', -- manual | auto
state TEXT NOT NULL DEFAULT 'open', -- open | finalized
started_at TIMESTAMP NOT NULL,
finalized_at TIMESTAMP
);
CREATE TABLE run_results (
run_id INTEGER NOT NULL,
registry_id INTEGER NOT NULL,
ip_address TEXT NOT NULL,
cycle_id INTEGER NOT NULL,
verdict TEXT NOT NULL, -- pass | partial | fail | cancelled
verdict_derived INTEGER NOT NULL DEFAULT 0, -- 1: computed from checks, not stored by the orchestrator
aggregated_at TIMESTAMP NOT NULL,
expected_checks INTEGER, -- NULL when unknown
recorded_checks INTEGER NOT NULL DEFAULT 0, -- checks stored when the verdict was made
PRIMARY KEY (run_id, registry_id)
);
CREATE INDEX idx_run_results_registry ON run_results(registry_id);
CREATE TABLE subnets (
cidr TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT ''
);
ALTER TABLE ip_queue ADD COLUMN run_id INTEGER;
ALTER TABLE checks ADD COLUMN run_id INTEGER;
CREATE INDEX idx_checks_run ON checks(run_id, registry_id, cycle_id);
-- ---- existing data -------------------------------------------------------
-- Ingress checks never stored the validator. The one holding the address in a
-- cycle is named by its fip_associated event.
UPDATE checks SET validator_id = COALESCE((
SELECT json_extract(e.payload, '$.validator_id') FROM events e
WHERE e.event_type = 'fip_associated' AND e.registry_id = checks.registry_id AND e.cycle_id = checks.cycle_id
ORDER BY e.id DESC LIMIT 1), '')
WHERE source LIKE 'inbound-site-%' AND validator_id = '';
-- Runs of existing data: cycles whose last checks end less than an hour apart
-- belong to one run.
CREATE TEMP TABLE _bf_cyc AS
WITH c AS (
SELECT registry_id, cycle_id, MAX(julianday(checked_at)) AS t, MIN(checked_at) AS first_at, MAX(checked_at) AS last_at
FROM checks GROUP BY registry_id, cycle_id
), o AS (
SELECT *, CASE WHEN t - LAG(t) OVER (ORDER BY t) > 60.0 / 1440.0 THEN 1 ELSE 0 END AS brk FROM c
)
SELECT *, 1 + SUM(brk) OVER (ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rid FROM o;
CREATE INDEX _bf_cyc_key ON _bf_cyc(registry_id, cycle_id);
INSERT INTO check_runs (id, kind, state, started_at, finalized_at)
SELECT rid, 'manual', 'finalized', MIN(first_at), MAX(last_at) FROM _bf_cyc GROUP BY rid;
UPDATE checks SET run_id = (
SELECT b.rid FROM _bf_cyc b WHERE b.registry_id = checks.registry_id AND b.cycle_id = checks.cycle_id);
UPDATE ip_queue SET run_id = (
SELECT b.rid FROM _bf_cyc b WHERE b.registry_id = ip_queue.registry_id AND b.cycle_id = ip_queue.cycle_id);
-- One result per address and run: the latest cycle of the address in the run.
-- The verdict is the orchestrator's when the queue row still holds that cycle,
-- otherwise it is derived from the stored checks.
INSERT INTO run_results (run_id, registry_id, ip_address, cycle_id, verdict, verdict_derived, aggregated_at, expected_checks, recorded_checks)
SELECT l.rid, l.registry_id, r.ip_address, l.cycle_id,
CASE WHEN q.id IS NOT NULL AND q.overall_result <> '' THEN q.overall_result
WHEN s.ok = 0 THEN 'fail' WHEN s.ok = s.n THEN 'pass' ELSE 'partial' END,
CASE WHEN q.id IS NOT NULL AND q.overall_result <> '' THEN 0 ELSE 1 END,
COALESCE(CASE WHEN q.overall_result <> '' THEN q.aggregated_at END, s.last_at),
(SELECT json_extract(e.payload, '$.checks') + json_extract(e.payload, '$.missing') FROM events e
WHERE e.event_type = 'aggregated' AND e.registry_id = l.registry_id AND e.cycle_id = l.cycle_id
ORDER BY e.id DESC LIMIT 1),
COALESCE((SELECT json_extract(e.payload, '$.checks') FROM events e
WHERE e.event_type = 'aggregated' AND e.registry_id = l.registry_id AND e.cycle_id = l.cycle_id
ORDER BY e.id DESC LIMIT 1), s.n_before)
FROM (SELECT rid, registry_id, MAX(cycle_id) AS cycle_id FROM _bf_cyc GROUP BY rid, registry_id) l
JOIN ip_registry r ON r.id = l.registry_id
JOIN (SELECT registry_id, cycle_id, COUNT(*) AS n, SUM(success) AS ok, MAX(checked_at) AS last_at,
SUM(CASE WHEN after_verdict = 0 THEN 1 ELSE 0 END) AS n_before
FROM checks GROUP BY registry_id, cycle_id) s ON s.registry_id = l.registry_id AND s.cycle_id = l.cycle_id
LEFT JOIN ip_queue q ON q.registry_id = l.registry_id AND q.cycle_id = l.cycle_id;
DROP TABLE _bf_cyc;
+5 -3
View File
@@ -31,8 +31,9 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
now := timeToDB(Now()) now := timeToDB(Now())
res, err := d.ExecContext(ctx, ` res, err := d.ExecContext(ctx, `
INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id, INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id,
source, check_type, target, success, latency_ms, detail, checked_at, created_at, recorded_at) source, check_type, target, success, latency_ms, detail, checked_at, created_at, recorded_at, run_id)
SELECT q.registry_id, q.cycle_id, q.id, ?, q.attempt_number, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? SELECT q.registry_id, q.cycle_id, q.id, ?, q.attempt_number, COALESCE(NULLIF(?, ''), q.owner_validator_id, ''),
?, ?, ?, ?, ?, ?, ?, ?, ?, q.run_id
FROM ip_queue q FROM ip_queue q
WHERE q.id=? AND q.attempt_number=? AND q.state NOT IN (?, ?, ?, ?) WHERE q.id=? AND q.attempt_number=? AND q.state NOT IN (?, ?, ?, ?)
ON CONFLICT(registry_id, cycle_id, source, check_type, target) DO UPDATE SET ON CONFLICT(registry_id, cycle_id, source, check_type, target) DO UPDATE SET
@@ -41,7 +42,8 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
latency_ms=excluded.latency_ms, latency_ms=excluded.latency_ms,
detail=excluded.detail, detail=excluded.detail,
checked_at=excluded.checked_at, checked_at=excluded.checked_at,
recorded_at=excluded.recorded_at recorded_at=excluded.recorded_at,
run_id=excluded.run_id
`, c.IPAddress, c.ValidatorID, c.Source, c.CheckType, c.Target, `, c.IPAddress, c.ValidatorID, c.Source, c.CheckType, c.Target,
c.Success, c.LatencyMS, c.Detail, timeToDB(c.CheckedAt), now, now, c.Success, c.LatencyMS, c.Detail, timeToDB(c.CheckedAt), now, now,
c.IPID, c.AttemptNumber, IPAggregating, IPDone, IPFailed, IPOccupied) c.IPID, c.AttemptNumber, IPAggregating, IPDone, IPFailed, IPOccupied)
+92 -12
View File
@@ -21,6 +21,7 @@ func (d *DB) SeedQueue(ctx context.Context, addresses []string) error {
defer tx.Rollback() defer tx.Rollback()
now := timeToDB(Now()) now := timeToDB(Now())
runID := int64(0)
for i, addr := range addresses { for i, addr := range addresses {
var exists bool var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE ip_address=?)`, addr).Scan(&exists); err != nil { if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE ip_address=?)`, addr).Scan(&exists); err != nil {
@@ -37,11 +38,16 @@ func (d *DB) SeedQueue(ctx context.Context, addresses []string) error {
if err != nil { if err != nil {
return err return err
} }
if runID == 0 {
if runID, err = openRunTx(ctx, tx, RunManual, now); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at) INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ip_address) DO NOTHING ON CONFLICT(ip_address) DO NOTHING
`, addr, i, IPQueued, registryID, cycle, now, now); err != nil { `, addr, i, IPQueued, registryID, cycle, runID, now, now); err != nil {
return fmt.Errorf("seed %s: %w", addr, err) return fmt.Errorf("seed %s: %w", addr, err)
} }
} }
@@ -194,16 +200,36 @@ func (d *DB) SetAggregating(ctx context.Context, ipID int64) error {
// FinishIP records the aggregated result and marks the IP done or failed. // FinishIP records the aggregated result and marks the IP done or failed.
func (d *DB) FinishIP(ctx context.Context, ipID int64, result string) error { func (d *DB) FinishIP(ctx context.Context, ipID int64, result string) error {
return d.FinishIPExpected(ctx, ipID, result, -1)
}
// FinishIPExpected is FinishIP that also records, in the address's run, how
// many checks were expected at the verdict (expected < 0: unknown) and
// finalizes the run if this was its last open address.
func (d *DB) FinishIPExpected(ctx context.Context, ipID int64, result string, expected int) error {
state := IPDone state := IPDone
if result == ResultFail { if result == ResultFail {
state = IPFailed state = IPFailed
} }
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := timeToDB(Now()) now := timeToDB(Now())
_, err := d.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE ip_queue SET state=?, overall_result=?, aggregated_at=?, updated_at=? UPDATE ip_queue SET state=?, overall_result=?, aggregated_at=?, updated_at=?
WHERE id=? WHERE id=?
`, state, result, now, now, ipID) `, state, result, now, now, ipID); err != nil {
return err return err
}
if err := upsertRunResultTx(ctx, tx, ipID, result, expected, now); err != nil {
return err
}
if err := finalizeRunsTx(ctx, tx, now); err != nil {
return err
}
return tx.Commit()
} }
// ReleaseFIP records that the floating IP has been disassociated and frees // ReleaseFIP records that the floating IP has been disassociated and frees
@@ -254,6 +280,9 @@ func (d *DB) MarkFIPOccupied(ctx context.Context, ipID int64, validatorID string
return err return err
} }
} }
if err := finalizeRunsTx(ctx, tx, now); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
@@ -304,6 +333,12 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
state=?, retry_count=?, overall_result=?, aggregated_at=?, updated_at=? state=?, retry_count=?, overall_result=?, aggregated_at=?, updated_at=?
WHERE id=? WHERE id=?
`, nextState, retryCount, ResultFail, now, now, ipID) `, nextState, retryCount, ResultFail, now, now, ipID)
if err == nil {
err = upsertRunResultTx(ctx, tx, ipID, ResultFail, -1, now)
}
if err == nil {
err = finalizeRunsTx(ctx, tx, now)
}
} }
if err != nil { if err != nil {
return err return err
@@ -337,6 +372,12 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
// sequence, so a batch's relative order is preserved and, critically, // sequence, so a batch's relative order is preserved and, critically,
// resubmitting the same list later reproduces the same relative order. // resubmitting the same list later reproduces the same relative order.
func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult, error) { func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult, error) {
return d.SubmitIPsAs(ctx, addresses, RunManual)
}
// SubmitIPsAs is SubmitIPs that names the kind of run it opens when no run is
// open (RunManual or RunAuto); an already open run is joined whatever the kind.
func (d *DB) SubmitIPsAs(ctx context.Context, addresses []string, kind string) (SubmitIPsResult, error) {
var result SubmitIPsResult var result SubmitIPsResult
if len(addresses) == 0 { if len(addresses) == 0 {
return result, fmt.Errorf("addresses must not be empty: %w", ErrValidation) return result, fmt.Errorf("addresses must not be empty: %w", ErrValidation)
@@ -354,6 +395,17 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
} }
now := timeToDB(Now()) now := timeToDB(Now())
runID := int64(0)
ensureRun := func() (int64, error) {
if runID == 0 {
id, err := openRunTx(ctx, tx, kind, now)
if err != nil {
return 0, err
}
runID = id
}
return runID, nil
}
for i, addr := range addresses { for i, addr := range addresses {
seq := base + i seq := base + i
@@ -369,10 +421,14 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
if cErr != nil { if cErr != nil {
return result, cErr return result, cErr
} }
rid, rErr := ensureRun()
if rErr != nil {
return result, rErr
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at) INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, addr, seq, IPQueued, registryID, cycle, now, now); err != nil { `, addr, seq, IPQueued, registryID, cycle, rid, now, now); err != nil {
return result, fmt.Errorf("insert %s: %w", addr, err) return result, fmt.Errorf("insert %s: %w", addr, err)
} }
result.Added = append(result.Added, addr) result.Added = append(result.Added, addr)
@@ -389,14 +445,18 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
if cErr != nil { if cErr != nil {
return result, cErr return result, cErr
} }
rid, rErr := ensureRun()
if rErr != nil {
return result, rErr
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE ip_queue SET UPDATE ip_queue SET
state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0, state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0,
attempt_number=attempt_number+1, cycle_id=?, lease_expires_at=NULL, egress_complete=0, attempt_number=attempt_number+1, cycle_id=?, lease_expires_at=NULL, egress_complete=0,
overall_result='', overall_result='', run_id=?,
assigned_at=NULL, checking_started_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=? assigned_at=NULL, checking_started_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=?
WHERE ip_address=? WHERE ip_address=?
`, IPQueued, seq, cycle, now, addr); err != nil { `, IPQueued, seq, cycle, rid, now, addr); err != nil {
return result, fmt.Errorf("requeue %s: %w", addr, err) return result, fmt.Errorf("requeue %s: %w", addr, err)
} }
result.Requeued = append(result.Requeued, addr) result.Requeued = append(result.Requeued, addr)
@@ -431,7 +491,12 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
// closed by the single-connection transactional UPDATE below). // closed by the single-connection transactional UPDATE below).
func (d *DB) CancelIP(ctx context.Context, ipID int64) error { func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
now := timeToDB(Now()) now := timeToDB(Now())
res, err := d.ExecContext(ctx, ` tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `
UPDATE ip_queue SET UPDATE ip_queue SET
state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='', state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='',
lease_expires_at=NULL, updated_at=? lease_expires_at=NULL, updated_at=?
@@ -443,7 +508,13 @@ func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("ip_id %d already finished: %w", ipID, ErrInvalidState) return fmt.Errorf("ip_id %d already finished: %w", ipID, ErrInvalidState)
} }
return nil if err := upsertRunResultTx(ctx, tx, ipID, ResultCancelled, -1, now); err != nil {
return err
}
if err := finalizeRunsTx(ctx, tx, now); err != nil {
return err
}
return tx.Commit()
} }
// DeleteIP permanently removes an ip_queue row, along with its full check // DeleteIP permanently removes an ip_queue row, along with its full check
@@ -463,6 +534,9 @@ func (d *DB) DeleteIP(ctx context.Context, ipID int64) error {
if err := deleteIPTx(ctx, tx, ipID); err != nil { if err := deleteIPTx(ctx, tx, ipID); err != nil {
return err return err
} }
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
@@ -497,6 +571,9 @@ func (d *DB) DeleteIPs(ctx context.Context, addresses []string) (DeleteIPsResult
result.Deleted = append(result.Deleted, addr) result.Deleted = append(result.Deleted, addr)
} }
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
return result, err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return result, err return result, err
} }
@@ -592,6 +669,9 @@ func (d *DB) ClearAllIPs(ctx context.Context) ([]string, error) {
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil {
return nil, fmt.Errorf("delete ip_queue rows: %w", err) return nil, fmt.Errorf("delete ip_queue rows: %w", err)
} }
if err := finalizeRunsTx(ctx, tx, now); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
+45
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"net/netip"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -120,6 +121,34 @@ func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
type RegistryFilter struct { type RegistryFilter struct {
Query string // substring of ip_address Query string // substring of ip_address
LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult
RunID int64 // only addresses that have a result in this run
Subnet string // only addresses inside this CIDR
}
// subnetIDs returns the registry ids of the addresses inside prefix. SQLite
// has no CIDR operators, so the registry's addresses are filtered here.
func (d *DB) subnetIDs(ctx context.Context, cidr string) ([]any, error) {
p, err := netip.ParsePrefix(cidr)
if err != nil {
return nil, fmt.Errorf("subnet %q: %v: %w", cidr, err, ErrValidation)
}
rows, err := d.QueryContext(ctx, `SELECT id, ip_address FROM ip_registry`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []any
for rows.Next() {
var id int64
var ip string
if err := rows.Scan(&id, &ip); err != nil {
return nil, err
}
if a, err := netip.ParseAddr(ip); err == nil && p.Contains(a) {
ids = append(ids, id)
}
}
return ids, rows.Err()
} }
// lastResultCond is the SQL form of fillRegistrySummary's LastResult rule, // lastResultCond is the SQL form of fillRegistrySummary's LastResult rule,
@@ -155,6 +184,22 @@ func (d *DB) ListRegistryPage(ctx context.Context, f RegistryFilter, limit, offs
conds = append(conds, lastResultCond) conds = append(conds, lastResultCond)
args = append(args, f.LastResult, f.LastResult) args = append(args, f.LastResult, f.LastResult)
} }
if f.RunID > 0 {
conds = append(conds, "r.id IN (SELECT registry_id FROM run_results WHERE run_id = ?)")
args = append(args, f.RunID)
}
if f.Subnet != "" {
ids, err := d.subnetIDs(ctx, f.Subnet)
if err != nil {
return nil, 0, err
}
if len(ids) == 0 {
conds = append(conds, "0 = 1")
} else {
conds = append(conds, "r.id IN ("+strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")+")")
args = append(args, ids...)
}
}
from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id ` from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id `
where := "" where := ""
if len(conds) > 0 { if len(conds) > 0 {
+387
View File
@@ -0,0 +1,387 @@
package db
import (
"context"
"database/sql"
"fmt"
"net/netip"
"sort"
"time"
)
// Run kinds and states. A run groups the cycles of one launch of checks — see
// migrations/0011_check_runs.sql.
const (
RunManual = "manual"
RunAuto = "auto"
RunOpen = "open"
RunFinalized = "finalized"
)
// CheckRun is one launch of checks. FinalizedAt is nil while it is open.
type CheckRun struct {
ID int64
Kind string
State string
StartedAt time.Time
FinalizedAt *time.Time
}
// RunCounts is the verdict tally of a run.
type RunCounts struct {
Addresses int
Pass int
Partial int
Fail int
Cancelled int
}
// RunSummary is a run with its verdict tally, for the run selector. Pending
// is the number of its queue rows still being processed (open runs only).
type RunSummary struct {
CheckRun
RunCounts
Pending int
Total int
}
// openRunTx returns the id of the open run, creating one of the given kind if
// none is open. Called when addresses enter the queue: while a run is open
// everything submitted or re-checked joins it.
func openRunTx(ctx context.Context, tx *sql.Tx, kind, now string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM check_runs WHERE state=? ORDER BY id DESC LIMIT 1`, RunOpen).Scan(&id)
if err == nil {
return id, nil
}
if err != sql.ErrNoRows {
return 0, err
}
if kind == "" {
kind = RunManual
}
res, err := tx.ExecContext(ctx, `INSERT INTO check_runs (kind, state, started_at) VALUES (?, ?, ?)`, kind, RunOpen, now)
if err != nil {
return 0, fmt.Errorf("open run: %w", err)
}
return res.LastInsertId()
}
// finalizeRunsTx finalizes every open run that has no queue row left in a
// non-terminal state, and drops finalized runs that ended up with no results
// at all (everything was deleted before any verdict). Called wherever a queue
// row reaches a terminal state or disappears.
func finalizeRunsTx(ctx context.Context, tx *sql.Tx, now string) error {
if _, err := tx.ExecContext(ctx, `
UPDATE check_runs SET state=?, finalized_at=COALESCE(
(SELECT MAX(aggregated_at) FROM run_results WHERE run_id=check_runs.id), ?)
WHERE state=? AND NOT EXISTS (
SELECT 1 FROM ip_queue q WHERE q.run_id=check_runs.id AND q.state NOT IN (?, ?, ?))
`, RunFinalized, now, RunOpen, IPDone, IPFailed, IPOccupied); err != nil {
return fmt.Errorf("finalize runs: %w", err)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM check_runs WHERE state=? AND NOT EXISTS (SELECT 1 FROM run_results r WHERE r.run_id=check_runs.id)
`, RunFinalized); err != nil {
return fmt.Errorf("drop empty runs: %w", err)
}
return nil
}
// FinalizeRuns finalizes runs whose addresses are all done. The orchestrator
// calls it every tick as a safety net; the terminal transitions already do it.
func (d *DB) FinalizeRuns(ctx context.Context) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
return err
}
return tx.Commit()
}
// upsertRunResultTx stores the verdict of the address's current cycle in its
// run. A re-check inside an open run replaces the earlier result. expected < 0
// means unknown. Rows without a run (queue rows that predate runs and never
// got one) are skipped.
func upsertRunResultTx(ctx context.Context, tx *sql.Tx, ipID int64, verdict string, expected int, now string) error {
var exp sql.NullInt64
if expected >= 0 {
exp = sql.NullInt64{Int64: int64(expected), Valid: true}
}
_, err := tx.ExecContext(ctx, `
INSERT INTO run_results (run_id, registry_id, ip_address, cycle_id, verdict, aggregated_at, expected_checks, recorded_checks)
SELECT q.run_id, q.registry_id, q.ip_address, q.cycle_id, ?, ?, ?,
(SELECT COUNT(*) FROM checks c WHERE c.registry_id=q.registry_id AND c.cycle_id=q.cycle_id)
FROM ip_queue q WHERE q.id=? AND q.run_id IS NOT NULL
ON CONFLICT(run_id, registry_id) DO UPDATE SET
cycle_id=excluded.cycle_id, verdict=excluded.verdict, verdict_derived=0,
aggregated_at=excluded.aggregated_at, expected_checks=excluded.expected_checks,
recorded_checks=excluded.recorded_checks
`, verdict, now, exp, ipID)
if err != nil {
return fmt.Errorf("record run result: %w", err)
}
return nil
}
// adoptOrphanQueueRows gives every live queue row without a run (rows that
// predate runs) the open run, creating one. Runs once after migrating.
func (d *DB) adoptOrphanQueueRows(ctx context.Context) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var n int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_queue WHERE run_id IS NULL AND state NOT IN (?, ?, ?)`,
IPDone, IPFailed, IPOccupied).Scan(&n); err != nil {
return err
}
if n == 0 {
return nil
}
now := timeToDB(Now())
id, err := openRunTx(ctx, tx, RunManual, now)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE ip_queue SET run_id=? WHERE run_id IS NULL AND state NOT IN (?, ?, ?)`,
id, IPDone, IPFailed, IPOccupied); err != nil {
return err
}
return tx.Commit()
}
// ListRuns returns every run, newest first, with its verdict tally.
func (d *DB) ListRuns(ctx context.Context) ([]RunSummary, error) {
rows, err := d.QueryContext(ctx, `
SELECT r.id, r.kind, r.state, r.started_at, r.finalized_at,
COALESCE(SUM(CASE WHEN x.verdict IS NOT NULL THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN x.verdict='pass' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN x.verdict='partial' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN x.verdict='fail' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN x.verdict='cancelled' THEN 1 ELSE 0 END), 0),
(SELECT COUNT(*) FROM ip_queue q WHERE q.run_id=r.id),
(SELECT COUNT(*) FROM ip_queue q WHERE q.run_id=r.id AND q.state NOT IN (?, ?, ?))
FROM check_runs r LEFT JOIN run_results x ON x.run_id=r.id
GROUP BY r.id ORDER BY r.id DESC
`, IPDone, IPFailed, IPOccupied)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RunSummary
for rows.Next() {
var s RunSummary
var started string
var finalized sql.NullString
if err := rows.Scan(&s.ID, &s.Kind, &s.State, &started, &finalized,
&s.Addresses, &s.Pass, &s.Partial, &s.Fail, &s.Cancelled, &s.Total, &s.Pending); err != nil {
return nil, err
}
if s.StartedAt, err = dbToTime(started); err != nil {
return nil, err
}
if s.FinalizedAt, err = nullStringToTimePtr(finalized); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// GetRun returns one run, or ErrNotFound.
func (d *DB) GetRun(ctx context.Context, id int64) (*CheckRun, error) {
var r CheckRun
var started string
var finalized sql.NullString
err := d.QueryRowContext(ctx, `SELECT id, kind, state, started_at, finalized_at FROM check_runs WHERE id=?`, id).
Scan(&r.ID, &r.Kind, &r.State, &started, &finalized)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("run %d: %w", id, ErrNotFound)
}
if err != nil {
return nil, err
}
if r.StartedAt, err = dbToTime(started); err != nil {
return nil, err
}
if r.FinalizedAt, err = nullStringToTimePtr(finalized); err != nil {
return nil, err
}
return &r, nil
}
// RunResult is one address's result in a run.
type RunResult struct {
RegistryID int64
IPAddress string
CycleID int
Verdict string
Derived bool
AggregatedAt time.Time
ExpectedChecks int // -1 when unknown
RecordedChecks int
}
// ListRunResults returns the results of a run in address order of insertion.
func (d *DB) ListRunResults(ctx context.Context, runID int64) ([]RunResult, error) {
rows, err := d.QueryContext(ctx, `
SELECT registry_id, ip_address, cycle_id, verdict, verdict_derived, aggregated_at, expected_checks, recorded_checks
FROM run_results WHERE run_id=? ORDER BY registry_id`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RunResult
for rows.Next() {
var r RunResult
var agg string
var exp sql.NullInt64
if err := rows.Scan(&r.RegistryID, &r.IPAddress, &r.CycleID, &r.Verdict, &r.Derived, &agg, &exp, &r.RecordedChecks); err != nil {
return nil, err
}
if r.AggregatedAt, err = dbToTime(agg); err != nil {
return nil, err
}
r.ExpectedChecks = -1
if exp.Valid {
r.ExpectedChecks = int(exp.Int64)
}
out = append(out, r)
}
return out, rows.Err()
}
// RunCheck is one stored check of a run's result cycle, with what the
// analytics needs to place it.
type RunCheck struct {
RegistryID int64
Source string
CheckType string
Target string
Success bool
ValidatorID string
Detail string
RecordedAt time.Time
AfterVerdict bool
}
// EachRunCheck calls fn for every check of the cycles that make up the run's
// results (the latest cycle of each address in the run), in one pass over the
// run_id index. fn must not call back into the DB (one connection).
func (d *DB) EachRunCheck(ctx context.Context, runID int64, fn func(RunCheck)) error {
rows, err := d.QueryContext(ctx, `
SELECT c.registry_id, c.source, c.check_type, c.target, c.success, c.validator_id, c.detail,
COALESCE(c.recorded_at, c.created_at), c.after_verdict
FROM checks c JOIN run_results r ON r.run_id=c.run_id AND r.registry_id=c.registry_id AND r.cycle_id=c.cycle_id
WHERE c.run_id=?`, runID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c RunCheck
var rec string
if err := rows.Scan(&c.RegistryID, &c.Source, &c.CheckType, &c.Target, &c.Success, &c.ValidatorID, &c.Detail, &rec, &c.AfterVerdict); err != nil {
return err
}
if c.RecordedAt, err = dbToTime(rec); err != nil {
return err
}
fn(c)
}
return rows.Err()
}
// RunDataVersion changes whenever a check of the run is written, so a cache of
// computed analytics can tell when it is stale.
func (d *DB) RunDataVersion(ctx context.Context, runID int64) (string, error) {
var maxID, n sql.NullInt64
var rec sql.NullString
if err := d.QueryRowContext(ctx, `SELECT MAX(id), COUNT(*), MAX(recorded_at) FROM checks WHERE run_id=?`, runID).Scan(&maxID, &n, &rec); err != nil {
return "", err
}
var res sql.NullString
if err := d.QueryRowContext(ctx, `SELECT MAX(aggregated_at) FROM run_results WHERE run_id=?`, runID).Scan(&res); err != nil {
return "", err
}
return fmt.Sprintf("%d/%d/%s/%s", maxID.Int64, n.Int64, rec.String, res.String), nil
}
// Subnet is one entry of the administrator's subnet list.
type Subnet struct {
CIDR string
Label string
}
// ListSubnets returns the configured subnets, sorted by prefix.
func (d *DB) ListSubnets(ctx context.Context) ([]Subnet, error) {
rows, err := d.QueryContext(ctx, `SELECT cidr, label FROM subnets`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Subnet
for rows.Next() {
var s Subnet
if err := rows.Scan(&s.CIDR, &s.Label); err != nil {
return nil, err
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
a, _ := netip.ParsePrefix(out[i].CIDR)
b, _ := netip.ParsePrefix(out[j].CIDR)
if a.Addr() != b.Addr() {
return a.Addr().Less(b.Addr())
}
return a.Bits() < b.Bits()
})
return out, nil
}
// ReplaceSubnets replaces the whole subnet list. Every entry must be a valid
// CIDR; entries are stored in canonical form (host bits cleared) and
// duplicates collapse.
func (d *DB) ReplaceSubnets(ctx context.Context, subnets []Subnet) error {
canon := map[string]string{}
for _, s := range subnets {
p, err := netip.ParsePrefix(s.CIDR)
if err != nil {
return fmt.Errorf("subnet %q: %v: %w", s.CIDR, err, ErrValidation)
}
canon[p.Masked().String()] = s.Label
}
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM subnets`); err != nil {
return err
}
for cidr, label := range canon {
if _, err := tx.ExecContext(ctx, `INSERT INTO subnets (cidr, label) VALUES (?, ?)`, cidr, label); err != nil {
return err
}
}
return tx.Commit()
}
// CountRecheckedInRun returns how many addresses have more than one cycle of
// checks inside the run (re-checked while the run was open).
func (d *DB) CountRecheckedInRun(ctx context.Context, runID int64) (int, error) {
var n int
err := d.QueryRowContext(ctx, `
SELECT COUNT(*) FROM (SELECT registry_id FROM checks WHERE run_id=? GROUP BY registry_id HAVING COUNT(DISTINCT cycle_id) > 1)
`, runID).Scan(&n)
return n, err
}
+389
View File
@@ -0,0 +1,389 @@
package db
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func submit(t *testing.T, d *DB, kind string, addrs ...string) {
t.Helper()
if _, err := d.SubmitIPsAs(context.Background(), addrs, kind); err != nil {
t.Fatal(err)
}
}
func finish(t *testing.T, d *DB, addr, verdict string, expected int) {
t.Helper()
ctx := context.Background()
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
if err := d.FinishIPExpected(ctx, ip.ID, verdict, expected); err != nil {
t.Fatal(err)
}
}
func runs(t *testing.T, d *DB) []RunSummary {
t.Helper()
r, err := d.ListRuns(context.Background())
if err != nil {
t.Fatal(err)
}
return r
}
// An address entering an idle queue opens a run; everything submitted while it
// is open joins it; it is finalized when the last address is done.
func TestRunOpensJoinsAndFinalizes(t *testing.T) {
d, ctx := newTestDB(t)
submit(t, d, RunAuto, "1.1.1.1", "2.2.2.2")
submit(t, d, RunManual, "3.3.3.3") // joins the open run, kind stays auto
rs := runs(t, d)
if len(rs) != 1 || rs[0].State != RunOpen || rs[0].Kind != RunAuto || rs[0].Total != 3 || rs[0].Pending != 3 {
t.Fatalf("expected one open auto run with 3 pending rows: %+v", rs)
}
a, _ := d.GetIPByAddress(ctx, "1.1.1.1")
c, _ := d.GetIPByAddress(ctx, "3.3.3.3")
if a.ID == 0 || c.ID == 0 {
t.Fatal("rows missing")
}
finish(t, d, "1.1.1.1", ResultPass, 22)
finish(t, d, "2.2.2.2", ResultPartial, 22)
if rs = runs(t, d); rs[0].State != RunOpen || rs[0].Addresses != 2 || rs[0].Pending != 1 {
t.Fatalf("one address still pending, the run stays open: %+v", rs[0])
}
finish(t, d, "3.3.3.3", ResultFail, -1)
rs = runs(t, d)
if rs[0].State != RunFinalized || rs[0].FinalizedAt == nil || rs[0].Pass != 1 || rs[0].Partial != 1 || rs[0].Fail != 1 || rs[0].Addresses != 3 {
t.Fatalf("expected a finalized run with the three verdicts: %+v", rs[0])
}
res, err := d.ListRunResults(ctx, rs[0].ID)
if err != nil || len(res) != 3 {
t.Fatalf("results: %v %v", res, err)
}
byIP := map[string]RunResult{}
for _, r := range res {
byIP[r.IPAddress] = r
}
if byIP["1.1.1.1"].ExpectedChecks != 22 || byIP["3.3.3.3"].ExpectedChecks != -1 || byIP["2.2.2.2"].Verdict != ResultPartial {
t.Fatalf("results: %+v", byIP)
}
}
// A re-check after the run is finalized opens a new run and leaves the old one
// as it was; the old cycle's checks keep their run.
func TestRecheckAfterFinalizeOpensNewRun(t *testing.T) {
d, ctx := newTestDB(t)
ip := checkingIP(t, d, "1.1.1.1")
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", true)); err != nil {
t.Fatal(err)
}
finish(t, d, "1.1.1.1", ResultPass, 1)
first := runs(t, d)[0]
if first.State != RunFinalized {
t.Fatalf("expected finalized: %+v", first)
}
submit(t, d, RunManual, "1.1.1.1") // re-check of a finished address
rs := runs(t, d)
if len(rs) != 2 || rs[0].State != RunOpen || rs[0].ID == first.ID {
t.Fatalf("a re-check after the run ended must open a new run: %+v", rs)
}
ip, _ = d.GetIPByAddress(ctx, "1.1.1.1")
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip, _ = d.GetIP(ctx, ip.ID)
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", false)); err != nil {
t.Fatal(err)
}
finish(t, d, "1.1.1.1", ResultFail, 1)
var n1, n2 int
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id=?`, first.ID).Scan(&n1)
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id=?`, rs[0].ID).Scan(&n2)
if n1 != 1 || n2 != 1 {
t.Fatalf("each run keeps its own cycle's check: %d %d", n1, n2)
}
old, _ := d.ListRunResults(ctx, first.ID)
cur, _ := d.ListRunResults(ctx, rs[0].ID)
if len(old) != 1 || old[0].Verdict != ResultPass || old[0].CycleID != 1 || len(cur) != 1 || cur[0].Verdict != ResultFail || cur[0].CycleID != 2 {
t.Fatalf("results must not cross: old=%+v cur=%+v", old, cur)
}
// The checks of a run are the ones of its result cycle, nothing else.
var got []RunCheck
if err := d.EachRunCheck(ctx, first.ID, func(c RunCheck) { got = append(got, c) }); err != nil || len(got) != 1 || !got[0].Success {
t.Fatalf("run 1 checks: %+v %v", got, err)
}
}
// A re-check while the run is still open joins it and replaces the address's
// result, so a run holds one result per address.
func TestRecheckInsideOpenRunReplacesResult(t *testing.T) {
d, ctx := newTestDB(t)
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
finish(t, d, "1.1.1.1", ResultFail, 1)
submit(t, d, RunManual, "1.1.1.1") // while 2.2.2.2 is still pending
finish(t, d, "1.1.1.1", ResultPass, 1)
finish(t, d, "2.2.2.2", ResultPass, 1)
rs := runs(t, d)
if len(rs) != 1 || rs[0].Addresses != 2 || rs[0].Pass != 2 || rs[0].Fail != 0 {
t.Fatalf("expected one run with the latest verdicts: %+v", rs)
}
res, _ := d.ListRunResults(ctx, rs[0].ID)
for _, r := range res {
if r.IPAddress == "1.1.1.1" && r.CycleID != 2 {
t.Fatalf("the re-checked address must show its latest cycle: %+v", r)
}
}
if n, err := d.CountRecheckedInRun(ctx, rs[0].ID); err != nil || n != 0 {
t.Fatalf("no checks stored, so no re-check counted: %d %v", n, err)
}
}
func TestRunEndsWhenQueueIsClearedOrDeleted(t *testing.T) {
d, ctx := newTestDB(t)
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
finish(t, d, "1.1.1.1", ResultPass, 1)
if _, err := d.ClearAllIPs(ctx); err != nil {
t.Fatal(err)
}
rs := runs(t, d)
if len(rs) != 1 || rs[0].State != RunFinalized || rs[0].Addresses != 1 {
t.Fatalf("clearing ends the run with what it has: %+v", rs)
}
// The next submission is a new run, not a join of the ended one.
submit(t, d, RunManual, "3.3.3.3")
if rs = runs(t, d); len(rs) != 2 || rs[0].State != RunOpen {
t.Fatalf("expected a new open run: %+v", rs)
}
// A run with no result at all disappears when its rows are deleted.
ip, _ := d.GetIPByAddress(ctx, "3.3.3.3")
if err := d.DeleteIP(ctx, ip.ID); err != nil {
t.Fatal(err)
}
if rs = runs(t, d); len(rs) != 1 {
t.Fatalf("an empty run must be dropped: %+v", rs)
}
}
func TestCancelAndRetryFailureRecordResults(t *testing.T) {
d, ctx := newTestDB(t)
submit(t, d, RunManual, "1.1.1.1", "2.2.2.2")
a, _ := d.GetIPByAddress(ctx, "1.1.1.1")
if err := d.CancelIP(ctx, a.ID); err != nil {
t.Fatal(err)
}
b, _ := d.GetIPByAddress(ctx, "2.2.2.2")
if err := d.RequeueOrFail(ctx, b.ID, "", 0); err != nil { // retries exhausted at once
t.Fatal(err)
}
rs := runs(t, d)
if rs[0].State != RunFinalized || rs[0].Cancelled != 1 || rs[0].Fail != 1 {
t.Fatalf("cancelled and failed addresses are results of the run: %+v", rs[0])
}
}
// Ingress checks name the validator that held the address.
func TestIngressCheckTakesValidatorOfTheAddress(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.RegisterValidator(ctx, "validator-7", "host", "port", "v"); err != nil {
t.Fatal(err)
}
ip := checkingIP(t, d, "1.1.1.1")
if _, err := d.ExecContext(ctx, `UPDATE ip_queue SET owner_validator_id='validator-7' WHERE id=?`, ip.ID); err != nil {
t.Fatal(err)
}
if _, err := d.UpsertCheckIfOpen(ctx, checkOf(ip, "icmp", true)); err != nil {
t.Fatal(err)
}
var v string
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE ip_id=?`, ip.ID).Scan(&v); err != nil || v != "validator-7" {
t.Fatalf("validator of an ingress check = %q err=%v", v, err)
}
// An explicit validator (egress checks) is kept.
c := checkOf(ip, "https", true)
c.Source, c.ValidatorID, c.Target = SourceEgress, "validator-9", "https://x"
if _, err := d.UpsertCheckIfOpen(ctx, c); err != nil {
t.Fatal(err)
}
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE ip_id=? AND source='egress'`, ip.ID).Scan(&v); err != nil || v != "validator-9" {
t.Fatalf("egress validator = %q err=%v", v, err)
}
}
func TestSubnetsReplaceAndValidate(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.ReplaceSubnets(ctx, []Subnet{{CIDR: "10.1.2.3/24", Label: "a"}, {CIDR: "10.0.0.0/8"}, {CIDR: "10.1.2.0/24", Label: "dup"}}); err != nil {
t.Fatal(err)
}
got, err := d.ListSubnets(ctx)
if err != nil || len(got) != 2 || got[0].CIDR != "10.0.0.0/8" || got[1].CIDR != "10.1.2.0/24" {
t.Fatalf("subnets must be canonical, de-duplicated and sorted by prefix: %+v %v", got, err)
}
if err := d.ReplaceSubnets(ctx, []Subnet{{CIDR: "nonsense"}}); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation, got %v", err)
}
if got, _ := d.ListSubnets(ctx); len(got) != 2 {
t.Fatalf("a rejected list must leave the old one: %+v", got)
}
if err := d.ReplaceSubnets(ctx, nil); err != nil {
t.Fatal(err)
}
if got, _ := d.ListSubnets(ctx); len(got) != 0 {
t.Fatalf("an empty list clears: %+v", got)
}
}
func TestRegistryFilterByRunAndSubnet(t *testing.T) {
d, ctx := newTestDB(t)
submit(t, d, RunManual, "10.0.0.1", "10.0.0.2", "10.0.1.1")
for _, a := range []string{"10.0.0.1", "10.0.0.2", "10.0.1.1"} {
finish(t, d, a, ResultPass, -1)
}
runID := runs(t, d)[0].ID
submit(t, d, RunManual, "10.0.0.2") // second run holds only this address
finish(t, d, "10.0.0.2", ResultFail, -1)
secondID := runs(t, d)[0].ID
count := func(f RegistryFilter) int {
t.Helper()
_, total, err := d.ListRegistryPage(ctx, f, 50, 0)
if err != nil {
t.Fatal(err)
}
return total
}
if n := count(RegistryFilter{RunID: runID}); n != 3 {
t.Errorf("run 1: %d", n)
}
if n := count(RegistryFilter{RunID: secondID}); n != 1 {
t.Errorf("run 2: %d", n)
}
if n := count(RegistryFilter{Subnet: "10.0.0.0/24"}); n != 2 {
t.Errorf("subnet /24: %d", n)
}
if n := count(RegistryFilter{RunID: secondID, Subnet: "10.0.1.0/24"}); n != 0 {
t.Errorf("run 2 and the other subnet: %d", n)
}
if n := count(RegistryFilter{Subnet: "192.168.0.0/16"}); n != 0 {
t.Errorf("subnet with no address: %d", n)
}
if _, _, err := d.ListRegistryPage(ctx, RegistryFilter{Subnet: "x"}, 10, 0); !errors.Is(err, ErrValidation) {
t.Errorf("bad subnet: %v", err)
}
}
// Migration 0011 on a database of version 10: runs are cut at pauses of more
// than an hour, results come from the queue row or the checks, ingress checks
// get their validator from the fip_associated event, and live queue rows
// without a run are adopted into an open run when the database is opened.
func TestMigration0011BuildsRunsFromExistingData(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "old.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
raw.SetMaxOpenConns(1)
for _, m := range migrations {
if m.version > 10 {
break
}
if _, err := raw.ExecContext(ctx, m.sql); err != nil {
t.Fatalf("migration %d: %v", m.version, err)
}
}
raw.ExecContext(ctx, `PRAGMA user_version=10`)
exec := func(q string, args ...any) {
t.Helper()
if _, err := raw.ExecContext(ctx, q, args...); err != nil {
t.Fatalf("seed: %v\n%s", err, q)
}
}
const ts = "2026-10-02T13:00:00Z"
for i, ip := range []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"} {
exec(`INSERT INTO ip_registry (id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at) VALUES (?, ?, ?, ?, 3, ?, ?)`, i+1, ip, ts, ts, ts, ts)
}
// 1.1.1.1 and 2.2.2.2 finish minutes apart (run 1); 1.1.1.1 is checked
// again three hours later (run 2). 3.3.3.3 is still in the queue.
exec(`INSERT INTO ip_queue (id, ip_address, sequence, state, overall_result, aggregated_at, registry_id, cycle_id, created_at, updated_at)
VALUES (1, '1.1.1.1', 1, 'done', 'fail', '2026-10-02T16:00:30Z', 1, 2, ?, ?), (2, '2.2.2.2', 2, 'done', 'pass', '2026-10-02T13:05:30Z', 2, 1, ?, ?),
(3, '3.3.3.3', 3, 'checking', '', NULL, 3, 1, ?, ?)`, ts, ts, ts, ts, ts, ts)
chk := func(reg, cyc int, src, typ string, ok int, at string) {
exec(`INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id, source, check_type, target, success, checked_at, created_at)
VALUES (?, ?, ?, 'x', 1, '', ?, ?, 't', ?, ?, ?)`, reg, cyc, reg, src, typ, ok, at, at)
}
chk(1, 1, "egress", "https", 1, "2026-10-02T13:00:10Z")
chk(1, 1, "inbound-site-1", "icmp", 1, "2026-10-02T13:00:20Z")
chk(2, 1, "egress", "https", 1, "2026-10-02T13:05:00Z")
chk(1, 2, "egress", "https", 0, "2026-10-02T16:00:10Z")
exec(`INSERT INTO events (source_type, source_id, ip_id, event_type, payload, occurred_at, registry_id, cycle_id) VALUES
('control-api', '', 1, 'fip_associated', '{"fip_id":"f","validator_id":"vkiplab-v5"}', ?, 1, 1),
('control-api', '', 2, 'aggregated', '{"result":"pass","checks":1,"passed":1,"missing":1}', ?, 2, 1)`, ts, ts)
raw.Close()
d, err := Open(ctx, path)
if err != nil {
t.Fatalf("open (runs migration 11): %v", err)
}
defer d.Close()
rs := runs(t, d)
// run 1 and run 2 from the checks, plus the open run that adopted 3.3.3.3
if len(rs) != 3 {
t.Fatalf("expected 3 runs, got %+v", rs)
}
var first, second, open RunSummary
for _, r := range rs {
switch {
case r.State == RunOpen:
open = r
case r.Addresses == 2:
first = r
default:
second = r
}
}
if first.Pass != 2 || first.Fail != 0 || second.Fail != 1 || second.Addresses != 1 || open.Total != 1 {
t.Fatalf("runs: first=%+v second=%+v open=%+v", first, second, open)
}
res, _ := d.ListRunResults(ctx, first.ID)
for _, r := range res {
if r.IPAddress == "1.1.1.1" && (r.CycleID != 1 || r.Verdict != ResultPass || !r.Derived) {
// the queue row holds the later cycle, so this one is derived from its checks
t.Errorf("1.1.1.1 in the first run: %+v", r)
}
if r.IPAddress == "2.2.2.2" && (r.ExpectedChecks != 2 || r.RecordedChecks != 1 || r.Verdict != ResultPass || r.Derived) {
t.Errorf("result from the aggregated event: %+v", r)
}
}
res2, _ := d.ListRunResults(ctx, second.ID)
if len(res2) != 1 || res2[0].CycleID != 2 || res2[0].Verdict != ResultFail || res2[0].Derived {
t.Errorf("second run result: %+v", res2)
}
var v string
if err := d.QueryRowContext(ctx, `SELECT validator_id FROM checks WHERE source='inbound-site-1'`).Scan(&v); err != nil || v != "vkiplab-v5" {
t.Errorf("ingress validator = %q err=%v", v, err)
}
var unset int
d.QueryRowContext(ctx, `SELECT COUNT(*) FROM checks WHERE run_id IS NULL`).Scan(&unset)
if unset != 0 {
t.Errorf("%d checks without a run", unset)
}
var ver int
d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver)
if ver != 11 {
t.Errorf("user_version = %d", ver)
}
}
@@ -240,7 +240,7 @@ func TestMigration0010MarksRowsAfterVerdict(t *testing.T) {
t.Errorf("ssh: after_verdict=%d recorded=%s created=%s", a, rec, cr) t.Errorf("ssh: after_verdict=%d recorded=%s created=%s", a, rec, cr)
} }
var ver int var ver int
if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver != 10 { if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver != 11 {
t.Errorf("user_version=%d err=%v", ver, err) t.Errorf("user_version=%d err=%v", ver, err)
} }
} }
+2 -2
View File
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access) t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
} }
} }
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 { if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts) t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts)
} }
} }
+204
View File
@@ -0,0 +1,204 @@
package httpapi
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"cloudipvalidator/internal/analytics"
"cloudipvalidator/internal/db"
)
// analyticsRunDTO is one entry of the run selector.
type analyticsRunDTO 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 is the number of queue rows of the run, Pending those still being
// processed (only an open run has any).
Total int `json:"total"`
Pending int `json:"pending"`
}
type subnetDTO struct {
CIDR string `json:"cidr"`
Label string `json:"label,omitempty"`
}
type subnetsDTO struct {
Subnets []subnetDTO `json:"subnets"`
}
// analyticsCache keeps the computed analysis of finalized runs. An entry is
// valid while the run's data version (checks written, results) is unchanged;
// the subnet list is part of the key because it changes the grouping.
type analyticsCache struct {
mu sync.Mutex
entries map[int64]analyticsEntry
}
type analyticsEntry struct {
version string
an *analytics.Analysis
}
func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.DB.ListRuns(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := make([]analyticsRunDTO, 0, len(runs))
for _, x := range runs {
out = append(out, analyticsRunDTO{
ID: x.ID, Kind: x.Kind, State: x.State, StartedAt: x.StartedAt, FinalizedAt: x.FinalizedAt,
Addresses: x.Addresses, Pass: x.Pass, Partial: x.Partial, Fail: x.Fail, Cancelled: x.Cancelled,
Total: x.Total, Pending: x.Pending,
})
}
writeJSON(w, http.StatusOK, out)
}
// analysisFor returns the analysis of a finalized run, from the cache when the
// run's data has not changed since it was computed. It writes the error
// response itself and returns nil when it cannot.
func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid run id")
return nil
}
ctx := r.Context()
run, err := s.DB.GetRun(ctx, id)
if err != nil {
writeDBError(w, err)
return nil
}
if run.State != db.RunFinalized {
writeError(w, http.StatusConflict, "run is still open: analytics are available for finished runs")
return nil
}
data, err := s.DB.RunDataVersion(ctx, id)
if err != nil {
writeDBError(w, err)
return nil
}
subnets, err := s.DB.ListSubnets(ctx)
if err != nil {
writeDBError(w, err)
return nil
}
var sb strings.Builder
for _, x := range subnets {
sb.WriteString(x.CIDR + "|" + x.Label + ";")
}
version := data + "#" + sb.String()
s.analytics.mu.Lock()
defer s.analytics.mu.Unlock()
if e, ok := s.analytics.entries[id]; ok && e.version == version {
return e.an
}
an, err := analytics.Load(ctx, s.DB, id)
if err != nil {
writeDBError(w, err)
return nil
}
if s.analytics.entries == nil {
s.analytics.entries = map[int64]analyticsEntry{}
}
s.analytics.entries[id] = analyticsEntry{version: version, an: an}
return an
}
func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
if an := s.analysisFor(w, r); an != nil {
writeJSON(w, http.StatusOK, an.Report)
}
}
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// handleAnalyticsList serves the address table behind one indicator
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all)
// or one ingress error class (kind = error, ?class=...), as JSON or, with
// ?format=csv, as a downloadable CSV file.
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
an := s.analysisFor(w, r)
if an == nil {
return
}
kind, class := r.PathValue("kind"), r.URL.Query().Get("class")
list, err := an.List(kind, class)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if r.URL.Query().Get("format") != "csv" {
writeJSON(w, http.StatusOK, list)
return
}
var buf bytes.Buffer
buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8
cw := csv.NewWriter(&buf)
cw.UseCRLF = true
_ = cw.Write(list.Columns)
_ = cw.WriteAll(list.Rows)
name := kind
if kind == analytics.ListError {
if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" {
name += "-" + slug
} else {
name += "-class"
}
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_run%s.csv"`, name, r.PathValue("id")))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListSubnets(r.Context())
if err != nil {
writeDBError(w, err)
return
}
out := subnetsDTO{Subnets: make([]subnetDTO, 0, len(list))}
for _, x := range list {
out.Subnets = append(out.Subnets, subnetDTO{CIDR: x.CIDR, Label: x.Label})
}
writeJSON(w, http.StatusOK, out)
}
// handleConfigPutSubnets replaces the whole subnet list. The list groups the
// addresses on the analytics page; with none configured they group by /24.
func (s *Server) handleConfigPutSubnets(w http.ResponseWriter, r *http.Request) {
var req subnetsDTO
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
in := make([]db.Subnet, 0, len(req.Subnets))
for _, x := range req.Subnets {
in = append(in, db.Subnet{CIDR: strings.TrimSpace(x.CIDR), Label: x.Label})
}
if err := s.DB.ReplaceSubnets(r.Context(), in); err != nil {
writeDBError(w, err)
return
}
s.handleConfigGetSubnets(w, r)
}
+226
View File
@@ -0,0 +1,226 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
"cloudipvalidator/internal/db"
)
// finishedRun builds one finished run of two addresses through the real
// queue paths and returns its id: 9.9.9.1 passes, 9.9.9.2 has a failed ssh.
func finishedRun(t *testing.T, d *db.DB) int64 {
t.Helper()
ctx := context.Background()
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.1", "9.9.9.2"}, db.RunManual); err != nil {
t.Fatal(err)
}
for _, addr := range []string{"9.9.9.1", "9.9.9.2"} {
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatal(err)
}
if err := d.SetChecking(ctx, ip.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip, _ = d.GetIP(ctx, ip.ID)
put := func(src, typ, target, detail string, ok bool) {
if _, err := d.UpsertCheckIfOpen(ctx, db.Check{IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
ValidatorID: "vkiplab-v1", Source: src, CheckType: typ, Target: target, Success: ok, Detail: detail, CheckedAt: db.Now()}); err != nil {
t.Fatal(err)
}
}
put(db.SourceEgress, "https", "https://a.test", "", true)
put(db.InboundSource(1), "icmp", addr, "", true)
sshOK := addr == "9.9.9.1"
put(db.InboundSource(1), "ssh", addr, "dial tcp: i/o timeout", sshOK)
verdict := db.ResultPass
if !sshOK {
verdict = db.ResultPartial
}
if err := d.FinishIPExpected(ctx, ip.ID, verdict, 3); err != nil {
t.Fatal(err)
}
}
rs, err := d.ListRuns(ctx)
if err != nil || len(rs) != 1 || rs[0].State != db.RunFinalized {
t.Fatalf("expected one finalized run: %+v %v", rs, err)
}
return rs[0].ID
}
func TestAnalyticsEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
id := finishedRun(t, d)
base := "/api/v1/admin/analytics/runs"
resp, body := fc.do(http.MethodGet, base, nil)
var list []analyticsRunDTO
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &list) != nil || len(list) != 1 ||
list[0].State != "finalized" || list[0].Addresses != 2 || list[0].Pass != 1 || list[0].Partial != 1 {
t.Fatalf("runs: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id), nil)
var rep struct {
Summary struct {
Addresses int `json:"addresses"`
IngressSSHAny int `json:"ingress_ssh_any_failed"`
IngressSSHAll int `json:"ingress_ssh_all_failed"`
} `json:"summary"`
Errors []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"errors"`
Sites struct {
Rows []struct {
Site string `json:"site"`
} `json:"rows"`
} `json:"sites"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Summary.Addresses != 2 ||
rep.Summary.IngressSSHAny != 1 || rep.Summary.IngressSSHAll != 1 ||
len(rep.Errors) != 1 || rep.Errors[0].Name != "SSH: таймаут" || len(rep.Sites.Rows) != 1 || rep.Sites.Rows[0].Site != "rxmsk" {
t.Fatalf("report: %d %s", resp.StatusCode, body)
}
// The list as JSON and as a CSV file with BOM and a download name.
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any", nil)
var l struct {
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/ingress_ssh_any?format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "\xef\xbb\xbf") ||
!strings.Contains(string(body), "9.9.9.2") || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil)
if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") {
t.Fatalf("error csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
for path, want := range map[string]int{
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
base + "/9999": http.StatusNotFound,
base + "/abc": http.StatusBadRequest,
base + "/9999/lists/ingress_ssh_any": http.StatusNotFound,
} {
if resp, body := fc.do(http.MethodGet, path, nil); resp.StatusCode != want {
t.Errorf("%s: %d %s, want %d", path, resp.StatusCode, body, want)
}
}
}
// An open run has no analytics yet.
func TestAnalyticsOfOpenRunIsRefused(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.1"}); err != nil {
t.Fatal(err)
}
rs, _ := d.ListRuns(context.Background())
resp, body := fc.do(http.MethodGet, "/api/v1/admin/analytics/runs/"+itoa64(rs[0].ID), nil)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("open run: %d %s", resp.StatusCode, body)
}
}
// The result is cached while the run is unchanged and recomputed when a
// check of the run is written or the subnet list changes.
func TestAnalyticsCacheFollowsData(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d)
path := "/api/v1/admin/analytics/runs/" + itoa64(id)
subnetsOf := func() []string {
_, body := fc.do(http.MethodGet, path, nil)
var rep struct {
Subnets []struct {
CIDR string `json:"cidr"`
} `json:"subnets"`
}
if err := json.Unmarshal(body, &rep); err != nil {
t.Fatal(err)
}
var out []string
for _, s := range rep.Subnets {
out = append(out, s.CIDR)
}
return out
}
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.9.0/24" {
t.Fatalf("without a list addresses group by /24: %v", got)
}
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "9.9.0.0/16", Label: "девятые"}}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("put subnets: %d %s", resp.StatusCode, body)
}
if got := subnetsOf(); len(got) != 1 || got[0] != "9.9.0.0/16" {
t.Fatalf("a changed subnet list must change the report: %v", got)
}
}
func TestSubnetsConfigEndpoints(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
resp, body := fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: " 10.1.2.3/24 ", Label: "a"}, {CIDR: "10.0.0.0/8"}}})
var got subnetsDTO
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 || got.Subnets[0].CIDR != "10.0.0.0/8" || got.Subnets[1].CIDR != "10.1.2.0/24" {
t.Fatalf("put: %d %s", resp.StatusCode, body)
}
if resp, _ = fc.do(http.MethodPut, "/api/v1/admin/config/subnets", subnetsDTO{Subnets: []subnetDTO{{CIDR: "garbage"}}}); resp.StatusCode != http.StatusBadRequest {
t.Fatalf("garbage must be a 400, got %d", resp.StatusCode)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/subnets", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &got) != nil || len(got.Subnets) != 2 {
t.Fatalf("a rejected list must leave the old one: %d %s", resp.StatusCode, body)
}
}
func TestRegistryRunAndSubnetFilters(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
id := finishedRun(t, d)
// an address outside the run
if _, err := d.SubmitIPs(context.Background(), []string{"8.8.8.8"}); err != nil {
t.Fatal(err)
}
count := func(q string) int {
t.Helper()
resp, body := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=50&"+q, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: %d %s", q, resp.StatusCode, body)
}
var p registryPageResponse
if err := json.Unmarshal(body, &p); err != nil {
t.Fatal(err)
}
return p.Total
}
if n := count("run=" + itoa64(id)); n != 2 {
t.Errorf("run filter: %d", n)
}
if n := count("subnet=" + url.QueryEscape("9.9.9.0/24")); n != 2 {
t.Errorf("subnet filter: %d", n)
}
if n := count("run=" + itoa64(id) + "&subnet=" + url.QueryEscape("8.8.8.0/24")); n != 0 {
t.Errorf("run and subnet together: %d", n)
}
for _, bad := range []string{"run=abc", "run=0", "subnet=nonsense"} {
if resp, _ := fc.do(http.MethodGet, "/api/v1/admin/registry?limit=5&"+bad, nil); resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: %d, want 400", bad, resp.StatusCode)
}
}
}
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
+16 -1
View File
@@ -2,6 +2,7 @@ package httpapi
import ( import (
"net/http" "net/http"
"net/netip"
"strconv" "strconv"
"strings" "strings"
@@ -22,7 +23,21 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result")} filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result"), Subnet: strings.TrimSpace(q.Get("subnet"))}
if filter.Subnet != "" {
if _, err := netip.ParsePrefix(filter.Subnet); err != nil {
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(filter.Subnet)+" (a CIDR such as 203.0.113.0/24 is expected)")
return
}
}
if v := q.Get("run"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid run "+strconv.Quote(v))
return
}
filter.RunID = id
}
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) { if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)") writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
return return
+5
View File
@@ -65,6 +65,11 @@ func (s *Server) routeTable() []route {
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin}, {"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin}, {"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin}, {"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin},
{"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin},
{"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin},
{"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin},
{"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, accessAdmin},
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin}, {"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin}, {"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin}, {"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
+2
View File
@@ -20,6 +20,8 @@ type Server struct {
Orch *orchestrator.Orchestrator Orch *orchestrator.Orchestrator
Log *slog.Logger Log *slog.Logger
Auth Authenticator Auth Authenticator
analytics analyticsCache
} }
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server { func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
+4 -1
View File
@@ -128,6 +128,9 @@ func (o *Orchestrator) Tick(ctx context.Context) {
if err := o.sweepExpiredLeases(ctx); err != nil { if err := o.sweepExpiredLeases(ctx); err != nil {
o.Log.Error("sweep expired leases", "err", err) o.Log.Error("sweep expired leases", "err", err)
} }
if err := o.DB.FinalizeRuns(ctx); err != nil {
o.Log.Error("finalize runs", "err", err)
}
if !o.Async { if !o.Async {
o.wg.Wait() o.wg.Wait()
} }
@@ -718,7 +721,7 @@ func (o *Orchestrator) aggregateAndRelease(ctx context.Context, item db.IPQueueI
result, passCount, missing := computeVerdict(checks, expected, o.Agg.MissingCountsAsFail) result, passCount, missing := computeVerdict(checks, expected, o.Agg.MissingCountsAsFail)
egress, ingress := countByLevel(checks) egress, ingress := countByLevel(checks)
if err := o.DB.FinishIP(ctx, item.ID, result); err != nil { if err := o.DB.FinishIPExpected(ctx, item.ID, result, expected); err != nil {
return err return err
} }
o.event(ctx, "control-api", "", &item.ID, "aggregated", o.event(ctx, "control-api", "", &item.ID, "aggregated",
+5 -1
View File
@@ -282,7 +282,11 @@ func (o *Orchestrator) doScan(ctx context.Context, opts ScanOptions) (scanResult
return res, err return res, err
} }
chunk := free[off:min(off+scanChunkSize, len(free))] chunk := free[off:min(off+scanChunkSize, len(free))]
r, err := o.DB.SubmitIPs(ctx, chunk) kind := db.RunManual
if opts.ClearFirst {
kind = db.RunAuto // the auto-cycle's scan; a manual scan never clears first
}
r, err := o.DB.SubmitIPsAs(ctx, chunk, kind)
res.submit.Added = append(res.submit.Added, r.Added...) res.submit.Added = append(res.submit.Added, r.Added...)
res.submit.Requeued = append(res.submit.Requeued, r.Requeued...) res.submit.Requeued = append(res.submit.Requeued, r.Requeued...)
res.submit.Reordered = append(res.submit.Reordered, r.Reordered...) res.submit.Reordered = append(res.submit.Reordered, r.Reordered...)