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:
1 parent
864208238f
commit
b7669c9e41
44 files changed
+4123
-62
No files matched your search
@@ -101,6 +101,10 @@ docs/ документация и планы доработок
|
||||
- Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками.
|
||||
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
|
||||
|
||||
**Запуски и аналитика**
|
||||
- Запуск — одна партия проверок: открывается, когда адрес попадает в пустую или обработанную очередь, принимает всё, что добавлено или перепроверено, пока открыт, и завершается, когда у всех его адресов есть итог. Перепроверка после завершения открывает новый запуск; результаты запусков не пересекаются.
|
||||
- Страница «Аналитика» показывает один завершённый запуск по фактическим проверкам: показатели, причины `partial`, подсети (список — в настройках), цели и типы проверок, площадки, классы ошибок, валидаторы; списки адресов выгружаются в CSV.
|
||||
|
||||
**Реестр**
|
||||
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
|
||||
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся.
|
||||
@@ -122,7 +126,8 @@ docs/ документация и планы доработок
|
||||
| Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` |
|
||||
| Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` |
|
||||
| Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) |
|
||||
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` |
|
||||
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result/run/subnet` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` |
|
||||
| Аналитика | `GET /admin/analytics/runs`, `GET /admin/analytics/runs/{id}`, `GET /admin/analytics/runs/{id}/lists/{kind}` (JSON или `?format=csv`), `GET`/`PUT /admin/config/subnets` |
|
||||
| Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` |
|
||||
| Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
|
||||
| Служебное | `GET /admin/validators`, `GET /healthz` |
|
||||
@@ -162,6 +167,7 @@ docs/ документация и планы доработок
|
||||
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
|
||||
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
|
||||
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) |
|
||||
| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска; списки адресов с выгрузкой в CSV |
|
||||
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
|
||||
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок |
|
||||
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается.
|
||||
@@ -202,6 +208,7 @@ scripts/run-local-e2e.sh # сквозной прог
|
||||
|
||||
| Дата | Веха | Документ |
|
||||
|---|---|---|
|
||||
| 2026-10-03 | Раздел «Аналитика»: запуски проверки (миграция `0011`), показатели и списки адресов по запуску, подсети, CSV; сайдбар: связь с control-api и выход наверху, группы разделов; фильтры реестра по запуску и подсети | [план](docs/changes/2026-10-03_16-39_analytics-section-plan.md) · [итог](docs/changes/2026-10-03_18-41_analytics-section-summary.md) · [макет](docs/mockups/analytics-mockup.html) · [USAGE](docs/USAGE.md#аналитика-запусков) · [API](docs/API.md#аналитика-запусков) |
|
||||
| 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) |
|
||||
| 2026-10-03 | Реестр: последний результат по уровням Egress и Ingress, «успешно из всего» и разбивка по типам проверок (поля `egress`, `ingress`, `last_cycle_id` в `GET /admin/registry`) | [план](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-plan.md) · [итог](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) |
|
||||
| 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) |
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
657d24d49686f04d3e9d9e431bb56ba8d7e2b3f6d865059de8315eceee6df53a control-api
|
||||
7a5227edddb89763f8db8ea56be5a1703626998498c3969b69272bfdb9d670b9 control-api
|
||||
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
|
||||
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
|
||||
8c636afb4f68b461376a1f5a010faa72cc0b0f17adf55611e9d924936d6fe115 admin-dashboard
|
||||
1e5c0c3e2857aa2e856d89a6d72db7f402179b9d912177040c6e1fc9217b86d6 admin-dashboard
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,8 +12,8 @@
|
||||
|
||||
| Группа | Таблицы | Можно чистить |
|
||||
|---|---|---|
|
||||
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `events` (журнал событий) | да |
|
||||
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle` | **нет** |
|
||||
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `check_runs` и `run_results` (запуски и итоги адресов в них), `events` (журнал событий) | да |
|
||||
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`, `subnets` (список подсетей для аналитики) | **нет** |
|
||||
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
|
||||
|
||||
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`;
|
||||
@@ -96,12 +96,14 @@ UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IS NOT NULL;
|
||||
UPDATE validators SET state = 'idle' WHERE state = 'assigned';
|
||||
-- порядок важен: сначала зависимые таблицы
|
||||
DELETE FROM ip_site_checks;
|
||||
DELETE FROM run_results;
|
||||
DELETE FROM check_runs;
|
||||
DELETE FROM checks;
|
||||
DELETE FROM events;
|
||||
DELETE FROM ip_queue;
|
||||
DELETE FROM ip_registry;
|
||||
-- нумерация снова с 1 (необязательно)
|
||||
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events');
|
||||
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events', 'check_runs');
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
@@ -167,6 +169,7 @@ CREATE TEMP TABLE doomed_ip AS
|
||||
SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM run_results WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
|
||||
|
||||
+68
@@ -705,6 +705,10 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
|
||||
результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из
|
||||
`total`».
|
||||
|
||||
Фильтры постраничного режима (только вместе с `limit`): `run` — только адреса,
|
||||
у которых есть результат в этом запуске (см. [«Аналитика запусков»](#аналитика-запусков)); `subnet` — только
|
||||
адреса внутри подсети (CIDR, например `203.0.113.0/24`). Неверный `run` или `subnet` — `400`.
|
||||
|
||||
### `GET /api/v1/admin/registry/{ip}`
|
||||
|
||||
Реестровая запись по одному адресу плюс вся сохранённая история проверок
|
||||
@@ -993,3 +997,67 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
|
||||
|
||||
Для полностью автоматизированного локального прогона (без ручных curl)
|
||||
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md).
|
||||
|
||||
## Аналитика запусков
|
||||
|
||||
Запуск — одна «партия» проверок. Он открывается, когда адрес попадает в пустую (или полностью обработанную)
|
||||
очередь; пока он открыт, в него входят все добавленные и перепроверяемые адреса. Запуск завершается, когда все его
|
||||
адреса получили итог (`done`, `failed`, `occupied`) либо удалены из очереди. Перепроверка после завершения запуска
|
||||
открывает **новый** запуск, прежний не меняется. Тип запуска: `auto` (скан автоцикла) или `manual`. Для одного адреса
|
||||
в запуске хранится результат его последнего цикла. Для данных, накопленных до появления запусков, запуски выделены по
|
||||
паузам: циклы, которые заканчиваются с промежутком меньше часа, образуют один запуск.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs`
|
||||
|
||||
Список запусков, новые первыми, для выбора на странице «Аналитика».
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 2, "kind": "manual", "state": "open", "started_at": "2026-10-03T15:30:00Z", "finalized_at": null,
|
||||
"addresses": 120, "pass": 40, "partial": 80, "fail": 0, "cancelled": 0, "total": 200, "pending": 80},
|
||||
{"id": 1, "kind": "manual", "state": "finalized", "started_at": "2026-10-02T13:46:45Z", "finalized_at": "2026-10-02T22:28:54Z",
|
||||
"addresses": 6440, "pass": 1962, "partial": 4478, "fail": 0, "cancelled": 0, "total": 6440, "pending": 0}
|
||||
]
|
||||
```
|
||||
|
||||
`addresses` — адреса с итогом, `total` — все адреса запуска в очереди, `pending` — ещё в работе.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs/{id}`
|
||||
|
||||
Все показатели страницы по одному **завершённому** запуску; открытый запуск — `409`, неизвестный — `404`.
|
||||
Считаются проверки последнего цикла каждого адреса в запуске, в том числе пришедшие позже вердикта (как факты).
|
||||
Результат кэшируется, пока данные запуска и список подсетей не менялись.
|
||||
|
||||
| Блок | Содержимое |
|
||||
|---|---|
|
||||
| `run` | `id`, `kind`, `state`, `started_at`, `finalized_at`, `duration_seconds`, `rechecked` (адресов с несколькими циклами в запуске) |
|
||||
| `summary` | `addresses`, `pass`, `partial`, `fail`, `cancelled`; `egress_ok`, `ingress_ok` (адреса, у которых все записанные проверки уровня успешны); `egress_https_any_failed` и `egress_https_all_failed` (хотя бы одна / все https-проверки провалены), `egress_https_all_targets_failed` (все цели полного набора); `ingress_ssh_any_failed`, `ingress_ssh_all_failed`; `addresses_per_minute` |
|
||||
| `reasons` | причины `partial`, каждый адрес один раз: «Только egress», «Ingress и egress», «Egress и неполный набор», «Ingress, egress и неполный набор», «Только неполный набор», «Только ingress»; нулевые не выдаются |
|
||||
| `quality` | `late_failed_checks_at_pass`, `late_failed_addresses_at_pass`, `ingress_failed_checks`, `ingress_failed_late`, `incomplete_addresses`, `pass_with_failed_addresses`, `pass_by_facts` |
|
||||
| `subnets` | по подсети: `cidr`, `label`, `addresses`, `pass`, `egress_ok`, `ingress_ok` (без списка подсетей — группы по /24; адрес вне списка — «прочие») |
|
||||
| `targets` | `types` (семейства egress-проверок), `targets` (хосты, по убыванию провалов https), `failed` (по типу: число адресов с провалом на каждую цель) |
|
||||
| `matrix` | по типу: строки «подсеть × цель» для подсетей с `partial` (`partial`, `percent` по целям) |
|
||||
| `sites` | `types` и строки площадок: `total` и `ok` проверок по типу |
|
||||
| `errors` | классы ошибок проваленных ingress-проверок («SSH: таймаут», «ICMP: нет ответа», …) со счётчиками |
|
||||
| `validators` | по валидатору: `total`, `ok` https-проверок egress |
|
||||
|
||||
Тип проверки — это `check_type` до первого дефиса: `tcp-22` и `tcp-443` дают `tcp`.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}`
|
||||
|
||||
Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`.
|
||||
`kind`: `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`;
|
||||
без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`,
|
||||
имя вида `ingress_ssh_all_run1.csv`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка,
|
||||
валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»).
|
||||
|
||||
### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets`
|
||||
|
||||
Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком:
|
||||
|
||||
```json
|
||||
{"subnets": [{"cidr": "83.166.248.0/21", "label": "москва"}, {"cidr": "10.0.0.0/8"}]}
|
||||
```
|
||||
|
||||
CIDR приводится к канонической записи (`10.1.2.3/24` → `10.1.2.0/24`), повторы схлопываются; неверный CIDR — `400`,
|
||||
список остаётся прежним. Адрес относится к самой узкой подходящей подсети.
|
||||
+7
-1
@@ -46,6 +46,11 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
||||
|
||||
## Страницы и что на них можно делать
|
||||
|
||||
Сайдбар слева: вверху логотип, под ним блок сессии (состояние связи с `control-api` — зелёный индикатор, красный
|
||||
«нет связи» при сбое, переключатель темы, при включённом входе имя пользователя и кнопка «Выйти»), ниже разделы в двух
|
||||
группах: «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок,
|
||||
Настройки).
|
||||
|
||||
| Страница | Назначение |
|
||||
|---|---|
|
||||
| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. |
|
||||
@@ -53,6 +58,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
||||
| `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
|
||||
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). |
|
||||
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
|
||||
| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). |
|
||||
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
|
||||
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
|
||||
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
|
||||
@@ -179,7 +185,7 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
|
||||
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
|
||||
относительный путь на этом же сайте).
|
||||
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
|
||||
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (внизу сайдбара) стирает cookie в браузере;
|
||||
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (вверху сайдбара) стирает cookie в браузере;
|
||||
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
|
||||
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
|
||||
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- [Как читать итоговый результат (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. Порядок по правилам проекта
|
||||
|
||||
План (этот файл) → реализация по шагам из п.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.
|
||||
- Подсказки при наведении не работают на сенсорных экранах и с клавиатуры (как и в макете).
|
||||
- В сайдбаре на узком экране (меню-«бургер») новый блок сессии я не просматривал отдельно.
|
||||
- Фильтр реестра по подсети ограничен адресами, попавшими в список подсетей: строка «прочие» без ссылки.
|
||||
@@ -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 "прочее"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
})
|
||||
}
|
||||
@@ -244,6 +244,8 @@ func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
|
||||
type registryQuery struct {
|
||||
Q string
|
||||
LastResult string
|
||||
Run int64 // only addresses with a result in this run
|
||||
Subnet string // only addresses inside this CIDR
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -264,6 +266,12 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
|
||||
if q.LastResult != "" {
|
||||
v.Set("last_result", q.LastResult)
|
||||
}
|
||||
if q.Run > 0 {
|
||||
v.Set("run", strconv.FormatInt(q.Run, 10))
|
||||
}
|
||||
if q.Subnet != "" {
|
||||
v.Set("subnet", q.Subnet)
|
||||
}
|
||||
var out registryPage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
||||
return out, err
|
||||
@@ -392,3 +400,104 @@ func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// analyticsRun is one entry of GET /admin/analytics/runs (the run selector).
|
||||
type analyticsRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at"`
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
Partial int `json:"partial"`
|
||||
Fail int `json:"fail"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) {
|
||||
var out []analyticsRun
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsReport returns the analytics of one finished run as the raw
|
||||
// JSON control-api computed; the page's script reads it as it is.
|
||||
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||
v := url.Values{}
|
||||
if class != "" {
|
||||
v.Set("class", class)
|
||||
}
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind)
|
||||
if len(v) > 0 {
|
||||
p += "?" + v.Encode()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// GetAnalyticsList returns one address table (JSON) of a run.
|
||||
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsListCSV returns the CSV file of one address table, with the
|
||||
// file name control-api proposed.
|
||||
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &apiErr{Status: 0, Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
msg := string(body)
|
||||
var er errorResponse
|
||||
if json.Unmarshal(body, &er) == nil && er.Error != "" {
|
||||
msg = er.Error
|
||||
}
|
||||
return nil, "", &apiErr{Status: resp.StatusCode, Message: msg}
|
||||
}
|
||||
return body, resp.Header.Get("Content-Disposition"), nil
|
||||
}
|
||||
|
||||
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
|
||||
type subnetEntry struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type subnetList struct {
|
||||
Subnets []subnetEntry `json:"subnets"`
|
||||
}
|
||||
|
||||
func (c *client) GetSubnets(ctx context.Context) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -37,6 +37,14 @@ type fakeControlAPI struct {
|
||||
inboundICMP bool
|
||||
|
||||
historyRetentionCycles int
|
||||
|
||||
// Analytics: the run selector, the report JSON per run, the lists per
|
||||
// "run/kind[/class]" and the subnet list of /settings.
|
||||
runs []analyticsRun
|
||||
reports map[int64]string
|
||||
lists map[string]string
|
||||
subnets subnetList
|
||||
analyticsReqs []string
|
||||
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
||||
// directly rather than this fake reimplementing OpenStack floating-IP
|
||||
// filtering (already covered by internal/orchestrator's own tests).
|
||||
@@ -469,6 +477,76 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.subnets
|
||||
if out.Subnets == nil {
|
||||
out.Subnets = []subnetEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in subnetList
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeAPIErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
for _, s := range in.Subnets {
|
||||
if !strings.Contains(s.CIDR, "/") {
|
||||
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
|
||||
return
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.subnets = in
|
||||
writeJSON(w, http.StatusOK, in)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.runs
|
||||
if out == nil {
|
||||
out = []analyticsRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
rep, ok := f.reports[id]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "run not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(rep))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
key := r.PathValue("id") + "/" + r.PathValue("kind")
|
||||
if c := r.URL.Query().Get("class"); c != "" {
|
||||
key += "/" + c
|
||||
}
|
||||
l, ok := f.lists[key]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
|
||||
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(l))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package dashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,6 +13,8 @@ type registryPageData struct {
|
||||
Items []registryItem
|
||||
Query string
|
||||
StatusFilter string
|
||||
Run int64 // drill-down from the analytics page
|
||||
Subnet string
|
||||
Page, PerPage int
|
||||
Total int
|
||||
Pager pagerData
|
||||
@@ -37,7 +41,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
||||
page := parsePage(r.URL.Query().Get("page"))
|
||||
query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage}
|
||||
run, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
if run < 0 {
|
||||
run = 0
|
||||
}
|
||||
subnet := strings.TrimSpace(r.URL.Query().Get("subnet"))
|
||||
if _, err := netip.ParsePrefix(subnet); err != nil {
|
||||
subnet = ""
|
||||
}
|
||||
query := registryQuery{Q: q, LastResult: status, Run: run, Subnet: subnet, Limit: perPage, Offset: (page - 1) * perPage}
|
||||
|
||||
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
||||
if err == nil {
|
||||
@@ -54,7 +66,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
if status != "" {
|
||||
params.Set("status", status)
|
||||
}
|
||||
if run > 0 {
|
||||
params.Set("run", strconv.FormatInt(run, 10))
|
||||
}
|
||||
if subnet != "" {
|
||||
params.Set("subnet", subnet)
|
||||
}
|
||||
data := registryPageData{
|
||||
Run: run,
|
||||
Subnet: subnet,
|
||||
Items: res.Items,
|
||||
Query: q,
|
||||
StatusFilter: status,
|
||||
|
||||
@@ -14,6 +14,8 @@ type settingsPageData struct {
|
||||
Inbound inboundChecksDTO
|
||||
// AutoCycle is the automatic-check-cycle panel's status/parameters.
|
||||
AutoCycle autoCycleDTO
|
||||
// Subnets is the list the analytics page groups addresses by.
|
||||
Subnets subnetList
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -26,7 +28,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
err = autoCycleErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if err == nil {
|
||||
err = subnetsErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}
|
||||
data.ActiveNav = "settings"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "settings_page", data)
|
||||
@@ -49,7 +55,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti
|
||||
if actionErr == nil {
|
||||
actionErr = autoCycleErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr)
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if actionErr == nil {
|
||||
actionErr = subnetsErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,6 +112,26 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// handleSubnetsPut saves the subnet list from the textarea of /settings: one
|
||||
// subnet per line, CIDR first and an optional label after a space.
|
||||
func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
|
||||
return
|
||||
}
|
||||
list := subnetList{Subnets: []subnetEntry{}}
|
||||
for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cidr, label, _ := strings.Cut(line, " ")
|
||||
list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)})
|
||||
}
|
||||
_, err := s.CA.PutSubnets(r.Context(), list)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// parseMinutes converts a form field holding a (possibly fractional) number
|
||||
// of minutes into whole seconds.
|
||||
func parseMinutes(raw string) (int, error) {
|
||||
|
||||
@@ -29,6 +29,10 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /registry", s.handleRegistryPage)
|
||||
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail)
|
||||
|
||||
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
|
||||
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
|
||||
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
|
||||
|
||||
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
|
||||
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
|
||||
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
|
||||
@@ -52,6 +56,7 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /settings", s.handleSettingsPage)
|
||||
mux.HandleFunc("PUT /settings", s.handleSettingsPut)
|
||||
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut)
|
||||
mux.HandleFunc("PUT /settings/subnets", s.handleSubnetsPut)
|
||||
mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut)
|
||||
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
|
||||
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/* Analytics page: renders the report of one finished run (embedded as JSON in
|
||||
#analytics-data) and opens the address lists behind the indicators and the
|
||||
error classes in a dialog. No other run's data is on the page. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var D = JSON.parse(document.getElementById('analytics-data').textContent);
|
||||
var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
|
||||
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
|
||||
function pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
|
||||
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; }
|
||||
function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
|
||||
|
||||
var state = { sort: 'worst', all: false, type: R.targets.types.indexOf('https') >= 0 ? 'https' : (R.targets.types[0] || '') };
|
||||
|
||||
/* ---- indicators ---- */
|
||||
function renderKpis() {
|
||||
var late = Q.late_failed_checks_at_pass;
|
||||
var tiles = [
|
||||
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
|
||||
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<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 + '&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 + '&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; });
|
||||
})();
|
||||
@@ -180,11 +180,9 @@ main > h2.section-title:first-child { margin-top: 0; }
|
||||
padding: 18px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.brand-chrome { display: flex; gap: 4px; }
|
||||
.brand-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
|
||||
.brand-mark {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -218,8 +216,10 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav-link.active svg { opacity: 1; }
|
||||
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
/* Session block at the top of the sidebar: link state to control-api, theme
|
||||
toggle and, with login enabled, who is signed in and the logout button. */
|
||||
.sidebar-session {
|
||||
display: grid; gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
@@ -227,9 +227,16 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 7px;
|
||||
}
|
||||
.sidebar-foot-status { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; margin: 0; }
|
||||
.session-api { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-user { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-group-title {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 10px;
|
||||
letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint);
|
||||
padding: 12px 9px 4px;
|
||||
}
|
||||
nav.nav-groups > .nav-group-title:first-child { padding-top: 0; }
|
||||
.pulse-dot {
|
||||
width: 6px; height: 6px; border-radius: 1px;
|
||||
background: var(--success);
|
||||
@@ -238,6 +245,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
||||
animation: pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
|
||||
.pulse-dot.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); animation: none; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.theme-toggle {
|
||||
@@ -568,13 +576,6 @@ textarea.autosize { overflow-y: hidden; resize: none; }
|
||||
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
|
||||
.login-card .field { flex: 0 0 auto; }
|
||||
.sidebar-user {
|
||||
margin-top: auto; margin-bottom: 8px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-user + .sidebar-foot { margin-top: 0; }
|
||||
|
||||
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
|
||||
progress {
|
||||
|
||||
@@ -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}}
|
||||
@@ -36,14 +36,29 @@
|
||||
{{define "sidebar_nav"}}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-chrome"><i></i><i></i><i></i></span>
|
||||
<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>
|
||||
</span>
|
||||
<span class="brand-name">Cloud IP Validator<small>admin</small></span>
|
||||
</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">
|
||||
<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>
|
||||
Реестр
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p>
|
||||
|
||||
<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="field" style="flex:1 1 260px">
|
||||
<label for="registry-q">Поиск по IP</label>
|
||||
|
||||
@@ -117,4 +117,23 @@ inbound-проверки к нулю, не трогая список площа
|
||||
</form>
|
||||
</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 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}}
|
||||
@@ -43,6 +43,9 @@ var scaleIndexesSchema string
|
||||
//go:embed migrations/0010_verdict_integrity.sql
|
||||
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
|
||||
// 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
|
||||
@@ -61,6 +64,7 @@ var migrations = []struct {
|
||||
{8, autoCycleSchema},
|
||||
{9, scaleIndexesSchema},
|
||||
{10, verdictIntegritySchema},
|
||||
{11, checkRunsSchema},
|
||||
}
|
||||
|
||||
type DB struct {
|
||||
@@ -96,6 +100,10 @@ func Open(ctx context.Context, path string) (*DB, error) {
|
||||
sqlDB.Close()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -31,8 +31,9 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
|
||||
now := timeToDB(Now())
|
||||
res, err := d.ExecContext(ctx, `
|
||||
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)
|
||||
SELECT q.registry_id, q.cycle_id, q.id, ?, q.attempt_number, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
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, COALESCE(NULLIF(?, ''), q.owner_validator_id, ''),
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, q.run_id
|
||||
FROM ip_queue q
|
||||
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
|
||||
@@ -41,7 +42,8 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
|
||||
latency_ms=excluded.latency_ms,
|
||||
detail=excluded.detail,
|
||||
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.Success, c.LatencyMS, c.Detail, timeToDB(c.CheckedAt), now, now,
|
||||
c.IPID, c.AttemptNumber, IPAggregating, IPDone, IPFailed, IPOccupied)
|
||||
|
||||
@@ -21,6 +21,7 @@ func (d *DB) SeedQueue(ctx context.Context, addresses []string) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
now := timeToDB(Now())
|
||||
runID := int64(0)
|
||||
for i, addr := range addresses {
|
||||
var exists bool
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if runID == 0 {
|
||||
if runID, err = openRunTx(ctx, tx, RunManual, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
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
|
||||
if result == ResultFail {
|
||||
state = IPFailed
|
||||
}
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := timeToDB(Now())
|
||||
_, err := d.ExecContext(ctx, `
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE ip_queue SET state=?, overall_result=?, aggregated_at=?, updated_at=?
|
||||
WHERE id=?
|
||||
`, state, result, now, now, ipID)
|
||||
return err
|
||||
`, state, result, now, now, ipID); err != nil {
|
||||
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
|
||||
@@ -254,6 +280,9 @@ func (d *DB) MarkFIPOccupied(ctx context.Context, ipID int64, validatorID string
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := finalizeRunsTx(ctx, tx, now); err != nil {
|
||||
return err
|
||||
}
|
||||
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=?
|
||||
WHERE id=?
|
||||
`, 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 {
|
||||
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,
|
||||
// resubmitting the same list later reproduces the same relative order.
|
||||
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
|
||||
if len(addresses) == 0 {
|
||||
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())
|
||||
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 {
|
||||
seq := base + i
|
||||
|
||||
@@ -369,10 +421,14 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
||||
if cErr != nil {
|
||||
return result, cErr
|
||||
}
|
||||
rid, rErr := ensureRun()
|
||||
if rErr != nil {
|
||||
return result, rErr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, addr, seq, IPQueued, registryID, cycle, now, now); err != nil {
|
||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, addr, seq, IPQueued, registryID, cycle, rid, now, now); err != nil {
|
||||
return result, fmt.Errorf("insert %s: %w", addr, err)
|
||||
}
|
||||
result.Added = append(result.Added, addr)
|
||||
@@ -389,14 +445,18 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
||||
if cErr != nil {
|
||||
return result, cErr
|
||||
}
|
||||
rid, rErr := ensureRun()
|
||||
if rErr != nil {
|
||||
return result, rErr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE ip_queue SET
|
||||
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,
|
||||
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=?
|
||||
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)
|
||||
}
|
||||
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).
|
||||
func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
|
||||
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
|
||||
state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='',
|
||||
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 {
|
||||
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
|
||||
@@ -463,6 +534,9 @@ func (d *DB) DeleteIP(ctx context.Context, ipID int64) error {
|
||||
if err := deleteIPTx(ctx, tx, ipID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -497,6 +571,9 @@ func (d *DB) DeleteIPs(ctx context.Context, addresses []string) (DeleteIPsResult
|
||||
result.Deleted = append(result.Deleted, addr)
|
||||
}
|
||||
|
||||
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -120,6 +121,34 @@ func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
|
||||
type RegistryFilter struct {
|
||||
Query string // substring of ip_address
|
||||
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,
|
||||
@@ -155,6 +184,22 @@ func (d *DB) ListRegistryPage(ctx context.Context, f RegistryFilter, limit, offs
|
||||
conds = append(conds, lastResultCond)
|
||||
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 `
|
||||
where := ""
|
||||
if len(conds) > 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||
}
|
||||
}
|
||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts)
|
||||
if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -22,7 +23,21 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
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) {
|
||||
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
||||
return
|
||||
|
||||
@@ -65,6 +65,11 @@ func (s *Server) routeTable() []route {
|
||||
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
|
||||
{"GET /api/v1/admin/registry", s.handleAdminRegistry, 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},
|
||||
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
|
||||
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
|
||||
|
||||
@@ -20,6 +20,8 @@ type Server struct {
|
||||
Orch *orchestrator.Orchestrator
|
||||
Log *slog.Logger
|
||||
Auth Authenticator
|
||||
|
||||
analytics analyticsCache
|
||||
}
|
||||
|
||||
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
||||
|
||||
@@ -128,6 +128,9 @@ func (o *Orchestrator) Tick(ctx context.Context) {
|
||||
if err := o.sweepExpiredLeases(ctx); err != nil {
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
o.event(ctx, "control-api", "", &item.ID, "aggregated",
|
||||
|
||||
@@ -282,7 +282,11 @@ func (o *Orchestrator) doScan(ctx context.Context, opts ScanOptions) (scanResult
|
||||
return res, err
|
||||
}
|
||||
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.Requeued = append(res.submit.Requeued, r.Requeued...)
|
||||
res.submit.Reordered = append(res.submit.Reordered, r.Reordered...)
|
||||
|
||||
Reference in new issue
Block a user