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
+4122
-61
No files matched your search
@@ -101,6 +101,10 @@ docs/ документация и планы доработок
|
|||||||
- Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками.
|
- Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками.
|
||||||
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
|
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
|
||||||
|
|
||||||
|
**Запуски и аналитика**
|
||||||
|
- Запуск — одна партия проверок: открывается, когда адрес попадает в пустую или обработанную очередь, принимает всё, что добавлено или перепроверено, пока открыт, и завершается, когда у всех его адресов есть итог. Перепроверка после завершения открывает новый запуск; результаты запусков не пересекаются.
|
||||||
|
- Страница «Аналитика» показывает один завершённый запуск по фактическим проверкам: показатели, причины `partial`, подсети (список — в настройках), цели и типы проверок, площадки, классы ошибок, валидаторы; списки адресов выгружаются в CSV.
|
||||||
|
|
||||||
**Реестр**
|
**Реестр**
|
||||||
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
|
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
|
||||||
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся.
|
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся.
|
||||||
@@ -122,7 +126,8 @@ docs/ документация и планы доработок
|
|||||||
| Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` |
|
| Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` |
|
||||||
| Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` |
|
| Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` |
|
||||||
| Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) |
|
| Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) |
|
||||||
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` |
|
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result/run/subnet` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` |
|
||||||
|
| Аналитика | `GET /admin/analytics/runs`, `GET /admin/analytics/runs/{id}`, `GET /admin/analytics/runs/{id}/lists/{kind}` (JSON или `?format=csv`), `GET`/`PUT /admin/config/subnets` |
|
||||||
| Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` |
|
| Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` |
|
||||||
| Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
|
| Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
|
||||||
| Служебное | `GET /admin/validators`, `GET /healthz` |
|
| Служебное | `GET /admin/validators`, `GET /healthz` |
|
||||||
@@ -162,6 +167,7 @@ docs/ документация и планы доработок
|
|||||||
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
|
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
|
||||||
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
|
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
|
||||||
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) |
|
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) |
|
||||||
|
| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска; списки адресов с выгрузкой в CSV |
|
||||||
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
|
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
|
||||||
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок |
|
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок |
|
||||||
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается.
|
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается.
|
||||||
@@ -202,6 +208,7 @@ scripts/run-local-e2e.sh # сквозной прог
|
|||||||
|
|
||||||
| Дата | Веха | Документ |
|
| Дата | Веха | Документ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-03 | Раздел «Аналитика»: запуски проверки (миграция `0011`), показатели и списки адресов по запуску, подсети, CSV; сайдбар: связь с control-api и выход наверху, группы разделов; фильтры реестра по запуску и подсети | [план](docs/changes/2026-10-03_16-39_analytics-section-plan.md) · [итог](docs/changes/2026-10-03_18-41_analytics-section-summary.md) · [макет](docs/mockups/analytics-mockup.html) · [USAGE](docs/USAGE.md#аналитика-запусков) · [API](docs/API.md#аналитика-запусков) |
|
||||||
| 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) |
|
| 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) |
|
||||||
| 2026-10-03 | Реестр: последний результат по уровням Egress и Ingress, «успешно из всего» и разбивка по типам проверок (поля `egress`, `ingress`, `last_cycle_id` в `GET /admin/registry`) | [план](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-plan.md) · [итог](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) |
|
| 2026-10-03 | Реестр: последний результат по уровням Egress и Ingress, «успешно из всего» и разбивка по типам проверок (поля `egress`, `ingress`, `last_cycle_id` в `GET /admin/registry`) | [план](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-plan.md) · [итог](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) |
|
||||||
| 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) |
|
| 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) |
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
657d24d49686f04d3e9d9e431bb56ba8d7e2b3f6d865059de8315eceee6df53a control-api
|
7a5227edddb89763f8db8ea56be5a1703626998498c3969b69272bfdb9d670b9 control-api
|
||||||
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
|
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
|
||||||
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
|
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
|
||||||
8c636afb4f68b461376a1f5a010faa72cc0b0f17adf55611e9d924936d6fe115 admin-dashboard
|
1e5c0c3e2857aa2e856d89a6d72db7f402179b9d912177040c6e1fc9217b86d6 admin-dashboard
|
||||||
Binary file not shown.
Binary file not shown.
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
| Группа | Таблицы | Можно чистить |
|
| Группа | Таблицы | Можно чистить |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `events` (журнал событий) | да |
|
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `check_runs` и `run_results` (запуски и итоги адресов в них), `events` (журнал событий) | да |
|
||||||
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle` | **нет** |
|
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`, `subnets` (список подсетей для аналитики) | **нет** |
|
||||||
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
|
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
|
||||||
|
|
||||||
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`;
|
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`;
|
||||||
@@ -96,12 +96,14 @@ UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IS NOT NULL;
|
|||||||
UPDATE validators SET state = 'idle' WHERE state = 'assigned';
|
UPDATE validators SET state = 'idle' WHERE state = 'assigned';
|
||||||
-- порядок важен: сначала зависимые таблицы
|
-- порядок важен: сначала зависимые таблицы
|
||||||
DELETE FROM ip_site_checks;
|
DELETE FROM ip_site_checks;
|
||||||
|
DELETE FROM run_results;
|
||||||
|
DELETE FROM check_runs;
|
||||||
DELETE FROM checks;
|
DELETE FROM checks;
|
||||||
DELETE FROM events;
|
DELETE FROM events;
|
||||||
DELETE FROM ip_queue;
|
DELETE FROM ip_queue;
|
||||||
DELETE FROM ip_registry;
|
DELETE FROM ip_registry;
|
||||||
-- нумерация снова с 1 (необязательно)
|
-- нумерация снова с 1 (необязательно)
|
||||||
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events');
|
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events', 'check_runs');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ CREATE TEMP TABLE doomed_ip AS
|
|||||||
SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg);
|
SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||||
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip);
|
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip);
|
||||||
DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip);
|
DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip);
|
||||||
|
DELETE FROM run_results WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||||
DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg);
|
DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||||
DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip);
|
DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip);
|
||||||
DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
|
DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
|
||||||
|
|||||||
+68
@@ -705,6 +705,10 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
|
|||||||
результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из
|
результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из
|
||||||
`total`».
|
`total`».
|
||||||
|
|
||||||
|
Фильтры постраничного режима (только вместе с `limit`): `run` — только адреса,
|
||||||
|
у которых есть результат в этом запуске (см. [«Аналитика запусков»](#аналитика-запусков)); `subnet` — только
|
||||||
|
адреса внутри подсети (CIDR, например `203.0.113.0/24`). Неверный `run` или `subnet` — `400`.
|
||||||
|
|
||||||
### `GET /api/v1/admin/registry/{ip}`
|
### `GET /api/v1/admin/registry/{ip}`
|
||||||
|
|
||||||
Реестровая запись по одному адресу плюс вся сохранённая история проверок
|
Реестровая запись по одному адресу плюс вся сохранённая история проверок
|
||||||
@@ -993,3 +997,67 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
|
|||||||
|
|
||||||
Для полностью автоматизированного локального прогона (без ручных curl)
|
Для полностью автоматизированного локального прогона (без ручных curl)
|
||||||
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md).
|
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md).
|
||||||
|
|
||||||
|
## Аналитика запусков
|
||||||
|
|
||||||
|
Запуск — одна «партия» проверок. Он открывается, когда адрес попадает в пустую (или полностью обработанную)
|
||||||
|
очередь; пока он открыт, в него входят все добавленные и перепроверяемые адреса. Запуск завершается, когда все его
|
||||||
|
адреса получили итог (`done`, `failed`, `occupied`) либо удалены из очереди. Перепроверка после завершения запуска
|
||||||
|
открывает **новый** запуск, прежний не меняется. Тип запуска: `auto` (скан автоцикла) или `manual`. Для одного адреса
|
||||||
|
в запуске хранится результат его последнего цикла. Для данных, накопленных до появления запусков, запуски выделены по
|
||||||
|
паузам: циклы, которые заканчиваются с промежутком меньше часа, образуют один запуск.
|
||||||
|
|
||||||
|
### `GET /api/v1/admin/analytics/runs`
|
||||||
|
|
||||||
|
Список запусков, новые первыми, для выбора на странице «Аналитика».
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"id": 2, "kind": "manual", "state": "open", "started_at": "2026-10-03T15:30:00Z", "finalized_at": null,
|
||||||
|
"addresses": 120, "pass": 40, "partial": 80, "fail": 0, "cancelled": 0, "total": 200, "pending": 80},
|
||||||
|
{"id": 1, "kind": "manual", "state": "finalized", "started_at": "2026-10-02T13:46:45Z", "finalized_at": "2026-10-02T22:28:54Z",
|
||||||
|
"addresses": 6440, "pass": 1962, "partial": 4478, "fail": 0, "cancelled": 0, "total": 6440, "pending": 0}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`addresses` — адреса с итогом, `total` — все адреса запуска в очереди, `pending` — ещё в работе.
|
||||||
|
|
||||||
|
### `GET /api/v1/admin/analytics/runs/{id}`
|
||||||
|
|
||||||
|
Все показатели страницы по одному **завершённому** запуску; открытый запуск — `409`, неизвестный — `404`.
|
||||||
|
Считаются проверки последнего цикла каждого адреса в запуске, в том числе пришедшие позже вердикта (как факты).
|
||||||
|
Результат кэшируется, пока данные запуска и список подсетей не менялись.
|
||||||
|
|
||||||
|
| Блок | Содержимое |
|
||||||
|
|---|---|
|
||||||
|
| `run` | `id`, `kind`, `state`, `started_at`, `finalized_at`, `duration_seconds`, `rechecked` (адресов с несколькими циклами в запуске) |
|
||||||
|
| `summary` | `addresses`, `pass`, `partial`, `fail`, `cancelled`; `egress_ok`, `ingress_ok` (адреса, у которых все записанные проверки уровня успешны); `egress_https_any_failed` и `egress_https_all_failed` (хотя бы одна / все https-проверки провалены), `egress_https_all_targets_failed` (все цели полного набора); `ingress_ssh_any_failed`, `ingress_ssh_all_failed`; `addresses_per_minute` |
|
||||||
|
| `reasons` | причины `partial`, каждый адрес один раз: «Только egress», «Ingress и egress», «Egress и неполный набор», «Ingress, egress и неполный набор», «Только неполный набор», «Только ingress»; нулевые не выдаются |
|
||||||
|
| `quality` | `late_failed_checks_at_pass`, `late_failed_addresses_at_pass`, `ingress_failed_checks`, `ingress_failed_late`, `incomplete_addresses`, `pass_with_failed_addresses`, `pass_by_facts` |
|
||||||
|
| `subnets` | по подсети: `cidr`, `label`, `addresses`, `pass`, `egress_ok`, `ingress_ok` (без списка подсетей — группы по /24; адрес вне списка — «прочие») |
|
||||||
|
| `targets` | `types` (семейства egress-проверок), `targets` (хосты, по убыванию провалов https), `failed` (по типу: число адресов с провалом на каждую цель) |
|
||||||
|
| `matrix` | по типу: строки «подсеть × цель» для подсетей с `partial` (`partial`, `percent` по целям) |
|
||||||
|
| `sites` | `types` и строки площадок: `total` и `ok` проверок по типу |
|
||||||
|
| `errors` | классы ошибок проваленных ingress-проверок («SSH: таймаут», «ICMP: нет ответа», …) со счётчиками |
|
||||||
|
| `validators` | по валидатору: `total`, `ok` https-проверок egress |
|
||||||
|
|
||||||
|
Тип проверки — это `check_type` до первого дефиса: `tcp-22` и `tcp-443` дают `tcp`.
|
||||||
|
|
||||||
|
### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}`
|
||||||
|
|
||||||
|
Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`.
|
||||||
|
`kind`: `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`;
|
||||||
|
без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`,
|
||||||
|
имя вида `ingress_ssh_all_run1.csv`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка,
|
||||||
|
валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»).
|
||||||
|
|
||||||
|
### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets`
|
||||||
|
|
||||||
|
Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"subnets": [{"cidr": "83.166.248.0/21", "label": "москва"}, {"cidr": "10.0.0.0/8"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
CIDR приводится к канонической записи (`10.1.2.3/24` → `10.1.2.0/24`), повторы схлопываются; неверный CIDR — `400`,
|
||||||
|
список остаётся прежним. Адрес относится к самой узкой подходящей подсети.
|
||||||
+7
-1
@@ -46,6 +46,11 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
|||||||
|
|
||||||
## Страницы и что на них можно делать
|
## Страницы и что на них можно делать
|
||||||
|
|
||||||
|
Сайдбар слева: вверху логотип, под ним блок сессии (состояние связи с `control-api` — зелёный индикатор, красный
|
||||||
|
«нет связи» при сбое, переключатель темы, при включённом входе имя пользователя и кнопка «Выйти»), ниже разделы в двух
|
||||||
|
группах: «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок,
|
||||||
|
Настройки).
|
||||||
|
|
||||||
| Страница | Назначение |
|
| Страница | Назначение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. |
|
| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. |
|
||||||
@@ -53,6 +58,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
|||||||
| `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
|
| `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
|
||||||
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). |
|
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). |
|
||||||
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
|
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
|
||||||
|
| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). |
|
||||||
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
|
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
|
||||||
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
|
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
|
||||||
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
|
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
|
||||||
@@ -179,7 +185,7 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
|
|||||||
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
|
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
|
||||||
относительный путь на этом же сайте).
|
относительный путь на этом же сайте).
|
||||||
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
|
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
|
||||||
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (внизу сайдбара) стирает cookie в браузере;
|
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (вверху сайдбара) стирает cookie в браузере;
|
||||||
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
|
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
|
||||||
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
|
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
|
||||||
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
|
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
- [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail)
|
- [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail)
|
||||||
- [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу)
|
- [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу)
|
||||||
- [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории)
|
- [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории)
|
||||||
|
- [Аналитика запусков](#аналитика-запусков)
|
||||||
- [Управление валидаторами](#управление-валидаторами)
|
- [Управление валидаторами](#управление-валидаторами)
|
||||||
- [Управление площадками (проберами)](#управление-площадками-проберами)
|
- [Управление площадками (проберами)](#управление-площадками-проберами)
|
||||||
- [Управление типами проверок пробера](#управление-типами-проверок-пробера)
|
- [Управление типами проверок пробера](#управление-типами-проверок-пробера)
|
||||||
@@ -419,6 +420,42 @@ curl -s http://<control-api>:8080/api/v1/admin/registry/203.0.113.10 | python3 -
|
|||||||
существует, когда впервые встречен, сколько всего было циклов) не
|
существует, когда впервые встречен, сколько всего было циклов) не
|
||||||
удаляется никогда.
|
удаляется никогда.
|
||||||
|
|
||||||
|
## Аналитика запусков
|
||||||
|
|
||||||
|
Страница `/analytics` в дашборде показывает результаты **одного завершённого запуска проверки**: итоги, причины
|
||||||
|
`partial`, подсети, провалы по целям, ingress по площадкам, классы ошибок, валидаторы и качество данных. Данные
|
||||||
|
других запусков на странице не участвуют, поэтому результаты разных прогонов не пересекаются.
|
||||||
|
|
||||||
|
**Что такое запуск.** Запуск открывается, когда адрес попадает в пустую (или полностью обработанную) очередь, а
|
||||||
|
скан автоцикла помечает его как `авто`. Пока он открыт, в него входят все добавленные и перепроверяемые адреса.
|
||||||
|
Когда у всех адресов запуска есть итог, запуск завершается и появляется в списке. Перепроверка после этого
|
||||||
|
открывает **новый** запуск; результаты прежнего остаются как были. Идущий запуск виден в списке, но недоступен
|
||||||
|
(«идёт, 120 из 800»). Запуски, накопленные до появления этой функции, выделены по паузам больше часа.
|
||||||
|
|
||||||
|
**Как читать числа.** Показатели считаются по фактическим проверкам последнего цикла каждого адреса, включая
|
||||||
|
пришедшие позже вердикта; вердикт системы показан рядом. `Egress OK` и `Ingress OK` — доли адресов, у которых все
|
||||||
|
записанные проверки уровня успешны. Блок «Качество данных» показывает, насколько вердикт расходится с проверками
|
||||||
|
(поздние результаты, неполный набор). После изменения «вердикт без опоздавших результатов» поздних результатов в новых
|
||||||
|
запусках быть не должно.
|
||||||
|
|
||||||
|
**Что можно открыть.** Карточки «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все
|
||||||
|
провалены», а также каждая строка блока «Классы ошибок ingress» открывают окно со списком адресов (для класса ошибок
|
||||||
|
— с распределением по валидаторам и статусом каждой проверки). В окне кнопки «Скачать CSV» (файл от control-api,
|
||||||
|
UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр»
|
||||||
|
с фильтром по запуску и подсети (`/registry?run=…&subnet=…`).
|
||||||
|
|
||||||
|
**Подсети.** Список задаётся на `/settings` (блок «Подсети»): по одной в строке, CIDR и, через пробел, подпись. Адрес
|
||||||
|
относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса
|
||||||
|
группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://<control-api>:8080/api/v1/admin/analytics/runs | python3 -m json.tool
|
||||||
|
curl -s http://<control-api>:8080/api/v1/admin/analytics/runs/1 | python3 -m json.tool
|
||||||
|
curl -s -o egress.csv "http://<control-api>:8080/api/v1/admin/analytics/runs/1/lists/egress_https_all?format=csv"
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробности и состав ответов — в [API.md](API.md#аналитика-запусков).
|
||||||
|
|
||||||
## Управление валидаторами
|
## Управление валидаторами
|
||||||
|
|
||||||
Список валидаторов и их текущее состояние:
|
Список валидаторов и их текущее состояние:
|
||||||
|
|||||||
@@ -139,3 +139,11 @@
|
|||||||
## 10. Порядок по правилам проекта
|
## 10. Порядок по правилам проекта
|
||||||
|
|
||||||
План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа.
|
План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа.
|
||||||
|
|
||||||
|
## 11. Уточнения при реализации (макет утверждён)
|
||||||
|
|
||||||
|
- Реализуется страница, точно повторяющая макет `docs/mockups/analytics-mockup.html`; отличия только там, где макет был заглушкой (данные, ссылки) или где этого требует рабочее приложение: время в подписях запусков — локальное время дашборда (как везде в нём), матрица «подсеть × цель» строится для любого типа проверки (в макете для `icmp` её не было), надпись про перепроверки показывается, только если они были.
|
||||||
|
- Решения по открытым вопросам плана приняты по умолчанию: перепроверка после завершения запуска открывает новый запуск; список подсетей хранится в БД (`subnets`) и задаётся на `/settings`, пока пуст — группы по /24.
|
||||||
|
- Миграция `0011` (запуски, `run_results`, `subnets`, `run_id` у `ip_queue` и `checks`, заполнение накопленных данных, валидатор у ingress-проверок). Раньше плана «Аналитика» выполнена миграция `0010` (вердикт без опоздавших результатов).
|
||||||
|
- Ссылки из подсетей ведут в «Реестр» с фильтрами `run` и `subnet` (добавлены в `GET /admin/registry` и `/registry`).
|
||||||
|
- **Сайдбар** (новое требование): убраны три точки возле логотипа; индикатор связи с control-api, переключатель темы и кнопка выхода подняты наверх в блок сессии под логотипом; индикатор краснеет («нет связи»), когда control-api недоступен; разделы разбиты на группы «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок, Настройки); нижний блок сайдбара убран.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Раздел «Аналитика» — итог
|
||||||
|
|
||||||
|
План: [2026-10-03_16-39_analytics-section-plan.md](2026-10-03_16-39_analytics-section-plan.md). Макет: [docs/mockups/analytics-mockup.html](../mockups/analytics-mockup.html).
|
||||||
|
|
||||||
|
Статус: код, тесты и документация готовы. На стенд не выложено (бинарники `bin/` не пересобирались), не закоммичено.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
**Запуски (миграция `0011`).** Новые таблицы `check_runs`, `run_results`, `subnets`; колонки `run_id` у `ip_queue` и `checks`. Запуск открывается, когда адрес попадает в пустую или полностью обработанную очередь (`SubmitIPsAs`, `SeedQueue`), принимает всё добавленное и перепроверенное, пока открыт, и завершается, когда у всех его адресов есть итог или они удалены (`finalizeRunsTx` в `FinishIPExpected`, `CancelIP`, `RequeueOrFail`, `MarkFIPOccupied`, удалении и очистке; плюс страховка в такте оркестратора). Скан автоцикла помечает запуск `auto`. Итог адреса (`run_results`) пишется при вердикте вместе с ожидаемым и записанным числом проверок. Ingress-проверка получает `validator_id` держателя адреса при записи. Накопленные данные размечены миграцией: запуски выделяются паузами больше часа, итоги берутся из очереди или считаются по проверкам (помечаются `verdict_derived`), валидатор ingress восстановлен из события `fip_associated`; живые строки очереди без запуска попадают в открытый запуск при открытии БД.
|
||||||
|
|
||||||
|
**Расчёт (`internal/analytics`).** Показатели считаются по фактам: все проверки последнего цикла каждого адреса запуска, в том числе пришедшие позже вердикта. Блоки: показатели, причины `partial`, качество данных, подсети, цели и матрица «подсеть × цель» по типам проверок, площадки, классы ошибок, валидаторы; списки адресов (4 индикатора и класс ошибки). Подсеть — самая узкая подходящая; без списка — /24.
|
||||||
|
|
||||||
|
**API.** `GET /admin/analytics/runs`, `/runs/{id}` (кэш по версии данных запуска и списку подсетей; открытый запуск — 409), `/runs/{id}/lists/{kind}` (JSON и `?format=csv`), `GET`/`PUT /admin/config/subnets`; фильтры `run` и `subnet` в `GET /admin/registry`.
|
||||||
|
|
||||||
|
**Страница `/analytics`.** Точно по макету: выбор запуска (идущий виден, недоступен), 12 карточек, причины `partial`, качество данных, подсети, egress по целям с вкладками по типу и тепловой картой, ingress по площадкам, классы ошибок, валидаторы; окна со списками адресов, подсказками и выгрузкой CSV (скачивание — с сервера, `Content-Disposition: attachment`). Стили `static/analytics.css` (классы с префиксом `an-`, не пересекаются со стилями дашборда), скрипт `static/analytics.js`. В `/settings` блок «Подсети». Из подсетей и матрицы — переход в «Реестр» с фильтром (`/registry?run=…&subnet=…`, плашка фильтра со сбросом).
|
||||||
|
|
||||||
|
**Сайдбар.** Убраны три точки у логотипа. Индикатор связи с control-api (краснеет «нет связи» при сбое), переключатель темы и кнопка «Выйти» подняты наверх, в блок сессии под логотипом. Разделы разбиты на «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка». Нижний блок убран.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
- `go build ./... && go vet ./... && go test ./...` проходят. Новые тесты: БД (жизненный цикл запуска, присоединение и новый запуск при перепроверке, очистка и удаление, отмена и провал по повторам, валидатор ingress, подсети, фильтры реестра, миграция `0011` на базе версии 10), `internal/analytics` (расчёт по синтетическим данным, подсети, классы ошибок, списки), API (отчёт, списки, CSV, кэш, подсети, фильтры, 409/404/400), дашборд (страница одного запуска и пустое состояние, прокси списков и CSV, сайдбар, индикатор связи, форма подсетей, drill-down в реестр).
|
||||||
|
- **Контрольные числа** на копии боевой БД (запуск 02.10, 6440 адресов) после миграции `0011` совпали с отчётами `analysis/`: 1962 `pass` / 4478 `partial`; Egress OK 2003, Ingress OK 6215; причины `partial` 4146 / 157 / 125 / 9 / 33 / 8; https: есть провалы 4409, все провалены 307 (по всем 5 целям 290); ssh: есть провалы 222, все провалены 7; провалы по целям 3841 / 2193 / 1872 / 1805 / 1728; первая строка матрицы 83.166.248.0/21: 81 / 57 / 51 / 86 / 46; классы ошибок 327 / 276 / 260 / 47 / 18 / 15 / 7; поздние провалы 246 у 50 адресов, 844 из 950 ingress-провалов после вердикта, неполный набор 167, `pass` по фактам 1912. Миграция на копии — около 8 с, расчёт отчёта — около 2,5 с.
|
||||||
|
- **В браузере** (headless Chrome, локальный control-api и дашборд на той же копии): страница на 1440 px и 390 px, без горизонтальной прокрутки на телефоне; окна списков (4 409 строк) и класса ошибок открываются, кнопки внутри окна видны, вкладки типов и матрица работают, CSV отдаётся файлом.
|
||||||
|
|
||||||
|
## Отличия от макета
|
||||||
|
|
||||||
|
- Время в подписях запусков — локальное время дашборда (в макете было UTC).
|
||||||
|
- Матрица «подсеть × цель» строится и для `icmp` (в макете для него её не было).
|
||||||
|
- Строка «Перепроверено внутри запуска» показывается, только если такие адреса есть (в БД стенда прежние циклы этих адресов уже удалены, поэтому 0).
|
||||||
|
- Карточки не переносят значение на вторую строку: минимальная ширина карточки 168 px, на телефоне шрифт значения меньше.
|
||||||
|
- Таблицы данных на телефоне прокручиваются вбок, а не превращаются в карточки, как остальные таблицы дашборда.
|
||||||
|
|
||||||
|
## Что не сделано и ограничения
|
||||||
|
|
||||||
|
- Выкладка на стенд: нужна пересборка `control-api` и `admin-dashboard` и миграция `0011` на боевой БД (копия БД перед ней обязательна; процедура — в памяти проекта и в `docs/SETUP.md`). После выкладки задать список подсетей клиента на `/settings` (45 подсетей из отчёта) — без него адреса группируются по /24.
|
||||||
|
- Подсказки при наведении не работают на сенсорных экранах и с клавиатуры (как и в макете).
|
||||||
|
- В сайдбаре на узком экране (меню-«бургер») новый блок сессии я не просматривал отдельно.
|
||||||
|
- Фильтр реестра по подсети ограничен адресами, попавшими в список подсетей: строка «прочие» без ссылки.
|
||||||
@@ -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 {
|
type registryQuery struct {
|
||||||
Q string
|
Q string
|
||||||
LastResult string
|
LastResult string
|
||||||
|
Run int64 // only addresses with a result in this run
|
||||||
|
Subnet string // only addresses inside this CIDR
|
||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
@@ -264,6 +266,12 @@ func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registr
|
|||||||
if q.LastResult != "" {
|
if q.LastResult != "" {
|
||||||
v.Set("last_result", q.LastResult)
|
v.Set("last_result", q.LastResult)
|
||||||
}
|
}
|
||||||
|
if q.Run > 0 {
|
||||||
|
v.Set("run", strconv.FormatInt(q.Run, 10))
|
||||||
|
}
|
||||||
|
if q.Subnet != "" {
|
||||||
|
v.Set("subnet", q.Subnet)
|
||||||
|
}
|
||||||
var out registryPage
|
var out registryPage
|
||||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
||||||
return out, err
|
return out, err
|
||||||
@@ -392,3 +400,104 @@ func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
|
|||||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
|
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// analyticsRun is one entry of GET /admin/analytics/runs (the run selector).
|
||||||
|
type analyticsRun struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
State string `json:"state"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
FinalizedAt *time.Time `json:"finalized_at"`
|
||||||
|
Addresses int `json:"addresses"`
|
||||||
|
Pass int `json:"pass"`
|
||||||
|
Partial int `json:"partial"`
|
||||||
|
Fail int `json:"fail"`
|
||||||
|
Cancelled int `json:"cancelled"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) {
|
||||||
|
var out []analyticsRun
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnalyticsReport returns the analytics of one finished run as the raw
|
||||||
|
// JSON control-api computed; the page's script reads it as it is.
|
||||||
|
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) {
|
||||||
|
var out json.RawMessage
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||||
|
v := url.Values{}
|
||||||
|
if class != "" {
|
||||||
|
v.Set("class", class)
|
||||||
|
}
|
||||||
|
if csv {
|
||||||
|
v.Set("format", "csv")
|
||||||
|
}
|
||||||
|
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind)
|
||||||
|
if len(v) > 0 {
|
||||||
|
p += "?" + v.Encode()
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnalyticsList returns one address table (JSON) of a run.
|
||||||
|
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) {
|
||||||
|
var out json.RawMessage
|
||||||
|
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnalyticsListCSV returns the CSV file of one address table, with the
|
||||||
|
// file name control-api proposed.
|
||||||
|
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &apiErr{Status: 0, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
msg := string(body)
|
||||||
|
var er errorResponse
|
||||||
|
if json.Unmarshal(body, &er) == nil && er.Error != "" {
|
||||||
|
msg = er.Error
|
||||||
|
}
|
||||||
|
return nil, "", &apiErr{Status: resp.StatusCode, Message: msg}
|
||||||
|
}
|
||||||
|
return body, resp.Header.Get("Content-Disposition"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
|
||||||
|
type subnetEntry struct {
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type subnetList struct {
|
||||||
|
Subnets []subnetEntry `json:"subnets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) GetSubnets(ctx context.Context) (subnetList, error) {
|
||||||
|
var out subnetList
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) {
|
||||||
|
var out subnetList
|
||||||
|
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ type fakeControlAPI struct {
|
|||||||
inboundICMP bool
|
inboundICMP bool
|
||||||
|
|
||||||
historyRetentionCycles int
|
historyRetentionCycles int
|
||||||
|
|
||||||
|
// Analytics: the run selector, the report JSON per run, the lists per
|
||||||
|
// "run/kind[/class]" and the subnet list of /settings.
|
||||||
|
runs []analyticsRun
|
||||||
|
reports map[int64]string
|
||||||
|
lists map[string]string
|
||||||
|
subnets subnetList
|
||||||
|
analyticsReqs []string
|
||||||
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
||||||
// directly rather than this fake reimplementing OpenStack floating-IP
|
// directly rather than this fake reimplementing OpenStack floating-IP
|
||||||
// filtering (already covered by internal/orchestrator's own tests).
|
// filtering (already covered by internal/orchestrator's own tests).
|
||||||
@@ -469,6 +477,76 @@ func (f *fakeControlAPI) handler() http.Handler {
|
|||||||
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
|
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := f.subnets
|
||||||
|
if out.Subnets == nil {
|
||||||
|
out.Subnets = []subnetEntry{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in subnetList
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeAPIErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range in.Subnets {
|
||||||
|
if !strings.Contains(s.CIDR, "/") {
|
||||||
|
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.subnets = in
|
||||||
|
writeJSON(w, http.StatusOK, in)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := f.runs
|
||||||
|
if out == nil {
|
||||||
|
out = []analyticsRun{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||||
|
rep, ok := f.reports[id]
|
||||||
|
if !ok {
|
||||||
|
writeAPIErr(w, http.StatusNotFound, "run not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(rep))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||||
|
key := r.PathValue("id") + "/" + r.PathValue("kind")
|
||||||
|
if c := r.URL.Query().Get("class"); c != "" {
|
||||||
|
key += "/" + c
|
||||||
|
}
|
||||||
|
l, ok := f.lists[key]
|
||||||
|
if !ok {
|
||||||
|
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("format") == "csv" {
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
|
||||||
|
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(l))
|
||||||
|
})
|
||||||
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ type registryPageData struct {
|
|||||||
Items []registryItem
|
Items []registryItem
|
||||||
Query string
|
Query string
|
||||||
StatusFilter string
|
StatusFilter string
|
||||||
|
Run int64 // drill-down from the analytics page
|
||||||
|
Subnet string
|
||||||
Page, PerPage int
|
Page, PerPage int
|
||||||
Total int
|
Total int
|
||||||
Pager pagerData
|
Pager pagerData
|
||||||
@@ -37,7 +41,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
||||||
page := parsePage(r.URL.Query().Get("page"))
|
page := parsePage(r.URL.Query().Get("page"))
|
||||||
query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage}
|
run, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||||
|
if run < 0 {
|
||||||
|
run = 0
|
||||||
|
}
|
||||||
|
subnet := strings.TrimSpace(r.URL.Query().Get("subnet"))
|
||||||
|
if _, err := netip.ParsePrefix(subnet); err != nil {
|
||||||
|
subnet = ""
|
||||||
|
}
|
||||||
|
query := registryQuery{Q: q, LastResult: status, Run: run, Subnet: subnet, Limit: perPage, Offset: (page - 1) * perPage}
|
||||||
|
|
||||||
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -54,7 +66,15 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if status != "" {
|
if status != "" {
|
||||||
params.Set("status", status)
|
params.Set("status", status)
|
||||||
}
|
}
|
||||||
|
if run > 0 {
|
||||||
|
params.Set("run", strconv.FormatInt(run, 10))
|
||||||
|
}
|
||||||
|
if subnet != "" {
|
||||||
|
params.Set("subnet", subnet)
|
||||||
|
}
|
||||||
data := registryPageData{
|
data := registryPageData{
|
||||||
|
Run: run,
|
||||||
|
Subnet: subnet,
|
||||||
Items: res.Items,
|
Items: res.Items,
|
||||||
Query: q,
|
Query: q,
|
||||||
StatusFilter: status,
|
StatusFilter: status,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type settingsPageData struct {
|
|||||||
Inbound inboundChecksDTO
|
Inbound inboundChecksDTO
|
||||||
// AutoCycle is the automatic-check-cycle panel's status/parameters.
|
// AutoCycle is the automatic-check-cycle panel's status/parameters.
|
||||||
AutoCycle autoCycleDTO
|
AutoCycle autoCycleDTO
|
||||||
|
// Subnets is the list the analytics page groups addresses by.
|
||||||
|
Subnets subnetList
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -26,7 +28,11 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
err = autoCycleErr
|
err = autoCycleErr
|
||||||
}
|
}
|
||||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||||
|
if err == nil {
|
||||||
|
err = subnetsErr
|
||||||
|
}
|
||||||
|
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}
|
||||||
data.ActiveNav = "settings"
|
data.ActiveNav = "settings"
|
||||||
data.Banner = bannerFor(err)
|
data.Banner = bannerFor(err)
|
||||||
s.renderPage(w, r, "settings_page", data)
|
s.renderPage(w, r, "settings_page", data)
|
||||||
@@ -49,7 +55,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti
|
|||||||
if actionErr == nil {
|
if actionErr == nil {
|
||||||
actionErr = autoCycleErr
|
actionErr = autoCycleErr
|
||||||
}
|
}
|
||||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr)
|
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||||
|
if actionErr == nil {
|
||||||
|
actionErr = subnetsErr
|
||||||
|
}
|
||||||
|
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -102,6 +112,26 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request)
|
|||||||
s.renderSettingsForm(w, r, err)
|
s.renderSettingsForm(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSubnetsPut saves the subnet list from the textarea of /settings: one
|
||||||
|
// subnet per line, CIDR first and an optional label after a space.
|
||||||
|
func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := subnetList{Subnets: []subnetEntry{}}
|
||||||
|
for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cidr, label, _ := strings.Cut(line, " ")
|
||||||
|
list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)})
|
||||||
|
}
|
||||||
|
_, err := s.CA.PutSubnets(r.Context(), list)
|
||||||
|
s.renderSettingsForm(w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
// parseMinutes converts a form field holding a (possibly fractional) number
|
// parseMinutes converts a form field holding a (possibly fractional) number
|
||||||
// of minutes into whole seconds.
|
// of minutes into whole seconds.
|
||||||
func parseMinutes(raw string) (int, error) {
|
func parseMinutes(raw string) (int, error) {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func (s *Server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /registry", s.handleRegistryPage)
|
mux.HandleFunc("GET /registry", s.handleRegistryPage)
|
||||||
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail)
|
mux.HandleFunc("GET /registry/{ip}", s.handleRegistryDetail)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
|
||||||
|
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
|
||||||
|
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
|
||||||
|
|
||||||
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
|
mux.HandleFunc("GET /validators", s.handleValidatorsPage)
|
||||||
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
|
mux.HandleFunc("POST /validators", s.handleValidatorCreate)
|
||||||
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
|
mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
|
||||||
@@ -52,6 +56,7 @@ func (s *Server) routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /settings", s.handleSettingsPage)
|
mux.HandleFunc("GET /settings", s.handleSettingsPage)
|
||||||
mux.HandleFunc("PUT /settings", s.handleSettingsPut)
|
mux.HandleFunc("PUT /settings", s.handleSettingsPut)
|
||||||
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut)
|
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut)
|
||||||
|
mux.HandleFunc("PUT /settings/subnets", s.handleSubnetsPut)
|
||||||
mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut)
|
mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut)
|
||||||
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
|
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
|
||||||
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
|
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
|
||||||
|
|||||||
@@ -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;
|
padding: 18px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
.brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||||
.brand-chrome { display: flex; gap: 4px; }
|
|
||||||
.brand-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 26px; height: 26px;
|
width: 26px; height: 26px;
|
||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
@@ -218,8 +216,10 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
|||||||
.nav-link.active svg { opacity: 1; }
|
.nav-link.active svg { opacity: 1; }
|
||||||
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
|
||||||
.sidebar-foot {
|
/* Session block at the top of the sidebar: link state to control-api, theme
|
||||||
margin-top: auto;
|
toggle and, with login enabled, who is signed in and the logout button. */
|
||||||
|
.sidebar-session {
|
||||||
|
display: grid; gap: 8px;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
@@ -227,9 +227,16 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
|||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 7px;
|
|
||||||
}
|
}
|
||||||
.sidebar-foot-status { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.session-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; margin: 0; }
|
||||||
|
.session-api { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.session-user { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.nav-group-title {
|
||||||
|
font-family: var(--font-display); font-weight: 700; font-size: 10px;
|
||||||
|
letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint);
|
||||||
|
padding: 12px 9px 4px;
|
||||||
|
}
|
||||||
|
nav.nav-groups > .nav-group-title:first-child { padding-top: 0; }
|
||||||
.pulse-dot {
|
.pulse-dot {
|
||||||
width: 6px; height: 6px; border-radius: 1px;
|
width: 6px; height: 6px; border-radius: 1px;
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
@@ -238,6 +245,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
|
|||||||
animation: pulse 2.2s ease-in-out infinite;
|
animation: pulse 2.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
|
||||||
|
.pulse-dot.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); animation: none; }
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
@@ -568,13 +576,6 @@ textarea.autosize { overflow-y: hidden; resize: none; }
|
|||||||
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
|
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
|
||||||
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
|
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
|
||||||
.login-card .field { flex: 0 0 auto; }
|
.login-card .field { flex: 0 0 auto; }
|
||||||
.sidebar-user {
|
|
||||||
margin-top: auto; margin-bottom: 8px;
|
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
||||||
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sidebar-user + .sidebar-foot { margin-top: 0; }
|
|
||||||
|
|
||||||
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
|
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
|
||||||
progress {
|
progress {
|
||||||
|
|||||||
@@ -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"}}
|
{{define "sidebar_nav"}}
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-chrome"><i></i><i></i><i></i></span>
|
|
||||||
<span class="brand-mark">
|
<span class="brand-mark">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="brand-name">Cloud IP Validator<small>admin</small></span>
|
<span class="brand-name">Cloud IP Validator<small>admin</small></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-groups">
|
<div class="sidebar-session">
|
||||||
|
{{$down := and .Banner.Message (not .Banner.Client)}}
|
||||||
|
<div class="session-row">
|
||||||
|
<span class="session-api" title="{{if $down}}Нет связи с control-api: {{.Banner.Message}}{{else}}Связь с control-api в порядке{{end}}"><span class="pulse-dot{{if $down}} down{{end}}"></span>control-api{{if $down}} · нет связи{{end}}</span>
|
||||||
|
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{if .AuthEnabled}}<form class="session-row" method="post" action="/logout">
|
||||||
|
<span class="session-user" title="{{.User}}">{{.User}}</span>
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
|
||||||
|
</form>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-groups" aria-label="Разделы">
|
||||||
|
<div class="nav-group-title">Мониторинг</div>
|
||||||
<a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">
|
<a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
|
||||||
Обзор
|
Обзор
|
||||||
@@ -56,6 +71,11 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/></svg>
|
||||||
Реестр
|
Реестр
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link{{if eq .ActiveNav "analytics"}} active{{end}}" {{if eq .ActiveNav "analytics"}}aria-current="page"{{end}} href="/analytics">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/></svg>
|
||||||
|
Аналитика
|
||||||
|
</a>
|
||||||
|
<div class="nav-group-title">Настройка</div>
|
||||||
<a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">
|
<a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg>
|
||||||
Валидаторы
|
Валидаторы
|
||||||
@@ -78,20 +98,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{{if .AuthEnabled}}
|
|
||||||
<form class="sidebar-user" method="post" action="/logout">
|
|
||||||
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
|
|
||||||
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="sidebar-foot">
|
|
||||||
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
|
|
||||||
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
|
||||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
||||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p>
|
Глубина хранимой истории на адрес настраивается на <a href="/settings">странице настроек</a>.</p>
|
||||||
|
|
||||||
<form id="registry-filter" class="panel" onsubmit="return false" style="margin-bottom:16px">
|
<form id="registry-filter" class="panel" onsubmit="return false" style="margin-bottom:16px">
|
||||||
|
{{if .Run}}<input type="hidden" name="run" value="{{.Run}}">{{end}}
|
||||||
|
{{if .Subnet}}<input type="hidden" name="subnet" value="{{.Subnet}}">{{end}}
|
||||||
|
{{if or .Run .Subnet}}<div class="panel-body" style="padding-bottom:0">
|
||||||
|
<span class="pill pill-info">Из аналитики:{{if .Run}} запуск {{.Run}}{{end}}{{if .Subnet}} · подсеть {{.Subnet}}{{end}}</span>
|
||||||
|
<a href="/registry" style="margin-left:10px">сбросить фильтр</a>
|
||||||
|
{{if .Run}}<a href="/analytics?run={{.Run}}" style="margin-left:10px">к аналитике</a>{{end}}
|
||||||
|
</div>{{end}}
|
||||||
<div class="panel-body field-row">
|
<div class="panel-body field-row">
|
||||||
<div class="field" style="flex:1 1 260px">
|
<div class="field" style="flex:1 1 260px">
|
||||||
<label for="registry-q">Поиск по IP</label>
|
<label for="registry-q">Поиск по IP</label>
|
||||||
|
|||||||
@@ -117,4 +117,23 @@ inbound-проверки к нулю, не трогая список площа
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-body">
|
||||||
|
<p class="muted" style="margin-bottom:16px">Подсети для страницы <a href="/analytics">«Аналитика»</a>: по ним
|
||||||
|
группируются адреса запуска. По одной в строке — <code>CIDR</code> и, через пробел, необязательная подпись
|
||||||
|
(<code>83.166.248.0/21 москва</code>). Адрес относится к самой узкой подходящей подсети, остальные идут в строку
|
||||||
|
«прочие». Пока список пуст, адреса группируются по /24.</p>
|
||||||
|
<form hx-put="/settings/subnets" hx-target="#settings-form-wrap" hx-swap="innerHTML">
|
||||||
|
<div class="field">
|
||||||
|
<label for="subnets_text">Подсети</label>
|
||||||
|
<textarea id="subnets_text" name="subnets" rows="8" spellcheck="false" placeholder="83.166.248.0/21 212.233.72.0/21 подпись">{{range .Subnets.Subnets}}{{.CIDR}}{{if .Label}} {{.Label}}{{end}}
|
||||||
|
{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -43,6 +43,9 @@ var scaleIndexesSchema string
|
|||||||
//go:embed migrations/0010_verdict_integrity.sql
|
//go:embed migrations/0010_verdict_integrity.sql
|
||||||
var verdictIntegritySchema string
|
var verdictIntegritySchema string
|
||||||
|
|
||||||
|
//go:embed migrations/0011_check_runs.sql
|
||||||
|
var checkRunsSchema string
|
||||||
|
|
||||||
// migrations is the ordered list of schema versions. Each entry's SQL is
|
// migrations is the ordered list of schema versions. Each entry's SQL is
|
||||||
// applied, in order, for any version greater than the database's current
|
// applied, in order, for any version greater than the database's current
|
||||||
// PRAGMA user_version — so a fresh database walks the whole list and an
|
// PRAGMA user_version — so a fresh database walks the whole list and an
|
||||||
@@ -61,6 +64,7 @@ var migrations = []struct {
|
|||||||
{8, autoCycleSchema},
|
{8, autoCycleSchema},
|
||||||
{9, scaleIndexesSchema},
|
{9, scaleIndexesSchema},
|
||||||
{10, verdictIntegritySchema},
|
{10, verdictIntegritySchema},
|
||||||
|
{11, checkRunsSchema},
|
||||||
}
|
}
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
@@ -96,6 +100,10 @@ func Open(ctx context.Context, path string) (*DB, error) {
|
|||||||
sqlDB.Close()
|
sqlDB.Close()
|
||||||
return nil, fmt.Errorf("migrate: %w", err)
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := d.adoptOrphanQueueRows(ctx); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("adopt queue rows into a run: %w", err)
|
||||||
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
-- Check runs (see docs/changes/2026-10-03_16-39_analytics-section-plan.md).
|
||||||
|
--
|
||||||
|
-- cycle_id counts per address, so it cannot tell one launch from another. A
|
||||||
|
-- run groups the cycles of one launch: it opens when an address enters an idle
|
||||||
|
-- queue, takes every address submitted or re-checked while it is open, and is
|
||||||
|
-- finalized when all its queue rows are terminal. checks.run_id and
|
||||||
|
-- ip_queue.run_id carry the membership; run_results keeps one row per address
|
||||||
|
-- and run (its latest cycle) with the verdict, which ip_queue overwrites on a
|
||||||
|
-- re-check.
|
||||||
|
--
|
||||||
|
-- No foreign keys on purpose: the manual cleanup in docs/ADMIN_CLEANUP.md
|
||||||
|
-- deletes from these tables freely.
|
||||||
|
|
||||||
|
CREATE TABLE check_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual', -- manual | auto
|
||||||
|
state TEXT NOT NULL DEFAULT 'open', -- open | finalized
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
finalized_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE run_results (
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
registry_id INTEGER NOT NULL,
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
cycle_id INTEGER NOT NULL,
|
||||||
|
verdict TEXT NOT NULL, -- pass | partial | fail | cancelled
|
||||||
|
verdict_derived INTEGER NOT NULL DEFAULT 0, -- 1: computed from checks, not stored by the orchestrator
|
||||||
|
aggregated_at TIMESTAMP NOT NULL,
|
||||||
|
expected_checks INTEGER, -- NULL when unknown
|
||||||
|
recorded_checks INTEGER NOT NULL DEFAULT 0, -- checks stored when the verdict was made
|
||||||
|
PRIMARY KEY (run_id, registry_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_run_results_registry ON run_results(registry_id);
|
||||||
|
|
||||||
|
CREATE TABLE subnets (
|
||||||
|
cidr TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ip_queue ADD COLUMN run_id INTEGER;
|
||||||
|
ALTER TABLE checks ADD COLUMN run_id INTEGER;
|
||||||
|
CREATE INDEX idx_checks_run ON checks(run_id, registry_id, cycle_id);
|
||||||
|
|
||||||
|
-- ---- existing data -------------------------------------------------------
|
||||||
|
-- Ingress checks never stored the validator. The one holding the address in a
|
||||||
|
-- cycle is named by its fip_associated event.
|
||||||
|
UPDATE checks SET validator_id = COALESCE((
|
||||||
|
SELECT json_extract(e.payload, '$.validator_id') FROM events e
|
||||||
|
WHERE e.event_type = 'fip_associated' AND e.registry_id = checks.registry_id AND e.cycle_id = checks.cycle_id
|
||||||
|
ORDER BY e.id DESC LIMIT 1), '')
|
||||||
|
WHERE source LIKE 'inbound-site-%' AND validator_id = '';
|
||||||
|
|
||||||
|
-- Runs of existing data: cycles whose last checks end less than an hour apart
|
||||||
|
-- belong to one run.
|
||||||
|
CREATE TEMP TABLE _bf_cyc AS
|
||||||
|
WITH c AS (
|
||||||
|
SELECT registry_id, cycle_id, MAX(julianday(checked_at)) AS t, MIN(checked_at) AS first_at, MAX(checked_at) AS last_at
|
||||||
|
FROM checks GROUP BY registry_id, cycle_id
|
||||||
|
), o AS (
|
||||||
|
SELECT *, CASE WHEN t - LAG(t) OVER (ORDER BY t) > 60.0 / 1440.0 THEN 1 ELSE 0 END AS brk FROM c
|
||||||
|
)
|
||||||
|
SELECT *, 1 + SUM(brk) OVER (ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rid FROM o;
|
||||||
|
|
||||||
|
CREATE INDEX _bf_cyc_key ON _bf_cyc(registry_id, cycle_id);
|
||||||
|
|
||||||
|
INSERT INTO check_runs (id, kind, state, started_at, finalized_at)
|
||||||
|
SELECT rid, 'manual', 'finalized', MIN(first_at), MAX(last_at) FROM _bf_cyc GROUP BY rid;
|
||||||
|
|
||||||
|
UPDATE checks SET run_id = (
|
||||||
|
SELECT b.rid FROM _bf_cyc b WHERE b.registry_id = checks.registry_id AND b.cycle_id = checks.cycle_id);
|
||||||
|
|
||||||
|
UPDATE ip_queue SET run_id = (
|
||||||
|
SELECT b.rid FROM _bf_cyc b WHERE b.registry_id = ip_queue.registry_id AND b.cycle_id = ip_queue.cycle_id);
|
||||||
|
|
||||||
|
-- One result per address and run: the latest cycle of the address in the run.
|
||||||
|
-- The verdict is the orchestrator's when the queue row still holds that cycle,
|
||||||
|
-- otherwise it is derived from the stored checks.
|
||||||
|
INSERT INTO run_results (run_id, registry_id, ip_address, cycle_id, verdict, verdict_derived, aggregated_at, expected_checks, recorded_checks)
|
||||||
|
SELECT l.rid, l.registry_id, r.ip_address, l.cycle_id,
|
||||||
|
CASE WHEN q.id IS NOT NULL AND q.overall_result <> '' THEN q.overall_result
|
||||||
|
WHEN s.ok = 0 THEN 'fail' WHEN s.ok = s.n THEN 'pass' ELSE 'partial' END,
|
||||||
|
CASE WHEN q.id IS NOT NULL AND q.overall_result <> '' THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(CASE WHEN q.overall_result <> '' THEN q.aggregated_at END, s.last_at),
|
||||||
|
(SELECT json_extract(e.payload, '$.checks') + json_extract(e.payload, '$.missing') FROM events e
|
||||||
|
WHERE e.event_type = 'aggregated' AND e.registry_id = l.registry_id AND e.cycle_id = l.cycle_id
|
||||||
|
ORDER BY e.id DESC LIMIT 1),
|
||||||
|
COALESCE((SELECT json_extract(e.payload, '$.checks') FROM events e
|
||||||
|
WHERE e.event_type = 'aggregated' AND e.registry_id = l.registry_id AND e.cycle_id = l.cycle_id
|
||||||
|
ORDER BY e.id DESC LIMIT 1), s.n_before)
|
||||||
|
FROM (SELECT rid, registry_id, MAX(cycle_id) AS cycle_id FROM _bf_cyc GROUP BY rid, registry_id) l
|
||||||
|
JOIN ip_registry r ON r.id = l.registry_id
|
||||||
|
JOIN (SELECT registry_id, cycle_id, COUNT(*) AS n, SUM(success) AS ok, MAX(checked_at) AS last_at,
|
||||||
|
SUM(CASE WHEN after_verdict = 0 THEN 1 ELSE 0 END) AS n_before
|
||||||
|
FROM checks GROUP BY registry_id, cycle_id) s ON s.registry_id = l.registry_id AND s.cycle_id = l.cycle_id
|
||||||
|
LEFT JOIN ip_queue q ON q.registry_id = l.registry_id AND q.cycle_id = l.cycle_id;
|
||||||
|
|
||||||
|
DROP TABLE _bf_cyc;
|
||||||
@@ -31,8 +31,9 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
|
|||||||
now := timeToDB(Now())
|
now := timeToDB(Now())
|
||||||
res, err := d.ExecContext(ctx, `
|
res, err := d.ExecContext(ctx, `
|
||||||
INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id,
|
INSERT INTO checks (registry_id, cycle_id, ip_id, ip_address, attempt_number, validator_id,
|
||||||
source, check_type, target, success, latency_ms, detail, checked_at, created_at, recorded_at)
|
source, check_type, target, success, latency_ms, detail, checked_at, created_at, recorded_at, run_id)
|
||||||
SELECT q.registry_id, q.cycle_id, q.id, ?, q.attempt_number, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
SELECT q.registry_id, q.cycle_id, q.id, ?, q.attempt_number, COALESCE(NULLIF(?, ''), q.owner_validator_id, ''),
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, q.run_id
|
||||||
FROM ip_queue q
|
FROM ip_queue q
|
||||||
WHERE q.id=? AND q.attempt_number=? AND q.state NOT IN (?, ?, ?, ?)
|
WHERE q.id=? AND q.attempt_number=? AND q.state NOT IN (?, ?, ?, ?)
|
||||||
ON CONFLICT(registry_id, cycle_id, source, check_type, target) DO UPDATE SET
|
ON CONFLICT(registry_id, cycle_id, source, check_type, target) DO UPDATE SET
|
||||||
@@ -41,7 +42,8 @@ func (d *DB) UpsertCheckIfOpen(ctx context.Context, c Check) (bool, error) {
|
|||||||
latency_ms=excluded.latency_ms,
|
latency_ms=excluded.latency_ms,
|
||||||
detail=excluded.detail,
|
detail=excluded.detail,
|
||||||
checked_at=excluded.checked_at,
|
checked_at=excluded.checked_at,
|
||||||
recorded_at=excluded.recorded_at
|
recorded_at=excluded.recorded_at,
|
||||||
|
run_id=excluded.run_id
|
||||||
`, c.IPAddress, c.ValidatorID, c.Source, c.CheckType, c.Target,
|
`, c.IPAddress, c.ValidatorID, c.Source, c.CheckType, c.Target,
|
||||||
c.Success, c.LatencyMS, c.Detail, timeToDB(c.CheckedAt), now, now,
|
c.Success, c.LatencyMS, c.Detail, timeToDB(c.CheckedAt), now, now,
|
||||||
c.IPID, c.AttemptNumber, IPAggregating, IPDone, IPFailed, IPOccupied)
|
c.IPID, c.AttemptNumber, IPAggregating, IPDone, IPFailed, IPOccupied)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func (d *DB) SeedQueue(ctx context.Context, addresses []string) error {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
now := timeToDB(Now())
|
now := timeToDB(Now())
|
||||||
|
runID := int64(0)
|
||||||
for i, addr := range addresses {
|
for i, addr := range addresses {
|
||||||
var exists bool
|
var exists bool
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE ip_address=?)`, addr).Scan(&exists); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE ip_address=?)`, addr).Scan(&exists); err != nil {
|
||||||
@@ -37,11 +38,16 @@ func (d *DB) SeedQueue(ctx context.Context, addresses []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if runID == 0 {
|
||||||
|
if runID, err = openRunTx(ctx, tx, RunManual, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at)
|
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(ip_address) DO NOTHING
|
ON CONFLICT(ip_address) DO NOTHING
|
||||||
`, addr, i, IPQueued, registryID, cycle, now, now); err != nil {
|
`, addr, i, IPQueued, registryID, cycle, runID, now, now); err != nil {
|
||||||
return fmt.Errorf("seed %s: %w", addr, err)
|
return fmt.Errorf("seed %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,17 +200,37 @@ func (d *DB) SetAggregating(ctx context.Context, ipID int64) error {
|
|||||||
|
|
||||||
// FinishIP records the aggregated result and marks the IP done or failed.
|
// FinishIP records the aggregated result and marks the IP done or failed.
|
||||||
func (d *DB) FinishIP(ctx context.Context, ipID int64, result string) error {
|
func (d *DB) FinishIP(ctx context.Context, ipID int64, result string) error {
|
||||||
|
return d.FinishIPExpected(ctx, ipID, result, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishIPExpected is FinishIP that also records, in the address's run, how
|
||||||
|
// many checks were expected at the verdict (expected < 0: unknown) and
|
||||||
|
// finalizes the run if this was its last open address.
|
||||||
|
func (d *DB) FinishIPExpected(ctx context.Context, ipID int64, result string, expected int) error {
|
||||||
state := IPDone
|
state := IPDone
|
||||||
if result == ResultFail {
|
if result == ResultFail {
|
||||||
state = IPFailed
|
state = IPFailed
|
||||||
}
|
}
|
||||||
|
tx, err := d.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
now := timeToDB(Now())
|
now := timeToDB(Now())
|
||||||
_, err := d.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE ip_queue SET state=?, overall_result=?, aggregated_at=?, updated_at=?
|
UPDATE ip_queue SET state=?, overall_result=?, aggregated_at=?, updated_at=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`, state, result, now, now, ipID)
|
`, state, result, now, now, ipID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := upsertRunResultTx(ctx, tx, ipID, result, expected, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := finalizeRunsTx(ctx, tx, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// ReleaseFIP records that the floating IP has been disassociated and frees
|
// ReleaseFIP records that the floating IP has been disassociated and frees
|
||||||
// the owning validator (if this address is still its current one, see
|
// the owning validator (if this address is still its current one, see
|
||||||
@@ -254,6 +280,9 @@ func (d *DB) MarkFIPOccupied(ctx context.Context, ipID int64, validatorID string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := finalizeRunsTx(ctx, tx, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +333,12 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
|
|||||||
state=?, retry_count=?, overall_result=?, aggregated_at=?, updated_at=?
|
state=?, retry_count=?, overall_result=?, aggregated_at=?, updated_at=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`, nextState, retryCount, ResultFail, now, now, ipID)
|
`, nextState, retryCount, ResultFail, now, now, ipID)
|
||||||
|
if err == nil {
|
||||||
|
err = upsertRunResultTx(ctx, tx, ipID, ResultFail, -1, now)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = finalizeRunsTx(ctx, tx, now)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -337,6 +372,12 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
|
|||||||
// sequence, so a batch's relative order is preserved and, critically,
|
// sequence, so a batch's relative order is preserved and, critically,
|
||||||
// resubmitting the same list later reproduces the same relative order.
|
// resubmitting the same list later reproduces the same relative order.
|
||||||
func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult, error) {
|
func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult, error) {
|
||||||
|
return d.SubmitIPsAs(ctx, addresses, RunManual)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitIPsAs is SubmitIPs that names the kind of run it opens when no run is
|
||||||
|
// open (RunManual or RunAuto); an already open run is joined whatever the kind.
|
||||||
|
func (d *DB) SubmitIPsAs(ctx context.Context, addresses []string, kind string) (SubmitIPsResult, error) {
|
||||||
var result SubmitIPsResult
|
var result SubmitIPsResult
|
||||||
if len(addresses) == 0 {
|
if len(addresses) == 0 {
|
||||||
return result, fmt.Errorf("addresses must not be empty: %w", ErrValidation)
|
return result, fmt.Errorf("addresses must not be empty: %w", ErrValidation)
|
||||||
@@ -354,6 +395,17 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := timeToDB(Now())
|
now := timeToDB(Now())
|
||||||
|
runID := int64(0)
|
||||||
|
ensureRun := func() (int64, error) {
|
||||||
|
if runID == 0 {
|
||||||
|
id, err := openRunTx(ctx, tx, kind, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
runID = id
|
||||||
|
}
|
||||||
|
return runID, nil
|
||||||
|
}
|
||||||
for i, addr := range addresses {
|
for i, addr := range addresses {
|
||||||
seq := base + i
|
seq := base + i
|
||||||
|
|
||||||
@@ -369,10 +421,14 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
|||||||
if cErr != nil {
|
if cErr != nil {
|
||||||
return result, cErr
|
return result, cErr
|
||||||
}
|
}
|
||||||
|
rid, rErr := ensureRun()
|
||||||
|
if rErr != nil {
|
||||||
|
return result, rErr
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, created_at, updated_at)
|
INSERT INTO ip_queue (ip_address, sequence, state, registry_id, cycle_id, run_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, addr, seq, IPQueued, registryID, cycle, now, now); err != nil {
|
`, addr, seq, IPQueued, registryID, cycle, rid, now, now); err != nil {
|
||||||
return result, fmt.Errorf("insert %s: %w", addr, err)
|
return result, fmt.Errorf("insert %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
result.Added = append(result.Added, addr)
|
result.Added = append(result.Added, addr)
|
||||||
@@ -389,14 +445,18 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
|||||||
if cErr != nil {
|
if cErr != nil {
|
||||||
return result, cErr
|
return result, cErr
|
||||||
}
|
}
|
||||||
|
rid, rErr := ensureRun()
|
||||||
|
if rErr != nil {
|
||||||
|
return result, rErr
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE ip_queue SET
|
UPDATE ip_queue SET
|
||||||
state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0,
|
state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0,
|
||||||
attempt_number=attempt_number+1, cycle_id=?, lease_expires_at=NULL, egress_complete=0,
|
attempt_number=attempt_number+1, cycle_id=?, lease_expires_at=NULL, egress_complete=0,
|
||||||
overall_result='',
|
overall_result='', run_id=?,
|
||||||
assigned_at=NULL, checking_started_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=?
|
assigned_at=NULL, checking_started_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=?
|
||||||
WHERE ip_address=?
|
WHERE ip_address=?
|
||||||
`, IPQueued, seq, cycle, now, addr); err != nil {
|
`, IPQueued, seq, cycle, rid, now, addr); err != nil {
|
||||||
return result, fmt.Errorf("requeue %s: %w", addr, err)
|
return result, fmt.Errorf("requeue %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
result.Requeued = append(result.Requeued, addr)
|
result.Requeued = append(result.Requeued, addr)
|
||||||
@@ -431,7 +491,12 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
|
|||||||
// closed by the single-connection transactional UPDATE below).
|
// closed by the single-connection transactional UPDATE below).
|
||||||
func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
|
func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
|
||||||
now := timeToDB(Now())
|
now := timeToDB(Now())
|
||||||
res, err := d.ExecContext(ctx, `
|
tx, err := d.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
res, err := tx.ExecContext(ctx, `
|
||||||
UPDATE ip_queue SET
|
UPDATE ip_queue SET
|
||||||
state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='',
|
state=?, overall_result=?, aggregated_at=?, owner_validator_id=NULL, fip_id='',
|
||||||
lease_expires_at=NULL, updated_at=?
|
lease_expires_at=NULL, updated_at=?
|
||||||
@@ -443,7 +508,13 @@ func (d *DB) CancelIP(ctx context.Context, ipID int64) error {
|
|||||||
if n, _ := res.RowsAffected(); n == 0 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
return fmt.Errorf("ip_id %d already finished: %w", ipID, ErrInvalidState)
|
return fmt.Errorf("ip_id %d already finished: %w", ipID, ErrInvalidState)
|
||||||
}
|
}
|
||||||
return nil
|
if err := upsertRunResultTx(ctx, tx, ipID, ResultCancelled, -1, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := finalizeRunsTx(ctx, tx, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteIP permanently removes an ip_queue row, along with its full check
|
// DeleteIP permanently removes an ip_queue row, along with its full check
|
||||||
@@ -463,6 +534,9 @@ func (d *DB) DeleteIP(ctx context.Context, ipID int64) error {
|
|||||||
if err := deleteIPTx(ctx, tx, ipID); err != nil {
|
if err := deleteIPTx(ctx, tx, ipID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +571,9 @@ func (d *DB) DeleteIPs(ctx context.Context, addresses []string) (DeleteIPsResult
|
|||||||
result.Deleted = append(result.Deleted, addr)
|
result.Deleted = append(result.Deleted, addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := finalizeRunsTx(ctx, tx, timeToDB(Now())); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@@ -592,6 +669,9 @@ func (d *DB) ClearAllIPs(ctx context.Context) ([]string, error) {
|
|||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil {
|
||||||
return nil, fmt.Errorf("delete ip_queue rows: %w", err)
|
return nil, fmt.Errorf("delete ip_queue rows: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := finalizeRunsTx(ctx, tx, now); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -120,6 +121,34 @@ func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
|
|||||||
type RegistryFilter struct {
|
type RegistryFilter struct {
|
||||||
Query string // substring of ip_address
|
Query string // substring of ip_address
|
||||||
LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult
|
LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult
|
||||||
|
RunID int64 // only addresses that have a result in this run
|
||||||
|
Subnet string // only addresses inside this CIDR
|
||||||
|
}
|
||||||
|
|
||||||
|
// subnetIDs returns the registry ids of the addresses inside prefix. SQLite
|
||||||
|
// has no CIDR operators, so the registry's addresses are filtered here.
|
||||||
|
func (d *DB) subnetIDs(ctx context.Context, cidr string) ([]any, error) {
|
||||||
|
p, err := netip.ParsePrefix(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("subnet %q: %v: %w", cidr, err, ErrValidation)
|
||||||
|
}
|
||||||
|
rows, err := d.QueryContext(ctx, `SELECT id, ip_address FROM ip_registry`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []any
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var ip string
|
||||||
|
if err := rows.Scan(&id, &ip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a, err := netip.ParseAddr(ip); err == nil && p.Contains(a) {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// lastResultCond is the SQL form of fillRegistrySummary's LastResult rule,
|
// lastResultCond is the SQL form of fillRegistrySummary's LastResult rule,
|
||||||
@@ -155,6 +184,22 @@ func (d *DB) ListRegistryPage(ctx context.Context, f RegistryFilter, limit, offs
|
|||||||
conds = append(conds, lastResultCond)
|
conds = append(conds, lastResultCond)
|
||||||
args = append(args, f.LastResult, f.LastResult)
|
args = append(args, f.LastResult, f.LastResult)
|
||||||
}
|
}
|
||||||
|
if f.RunID > 0 {
|
||||||
|
conds = append(conds, "r.id IN (SELECT registry_id FROM run_results WHERE run_id = ?)")
|
||||||
|
args = append(args, f.RunID)
|
||||||
|
}
|
||||||
|
if f.Subnet != "" {
|
||||||
|
ids, err := d.subnetIDs(ctx, f.Subnet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
conds = append(conds, "0 = 1")
|
||||||
|
} else {
|
||||||
|
conds = append(conds, "r.id IN ("+strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")+")")
|
||||||
|
args = append(args, ids...)
|
||||||
|
}
|
||||||
|
}
|
||||||
from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id `
|
from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id `
|
||||||
where := ""
|
where := ""
|
||||||
if len(conds) > 0 {
|
if len(conds) > 0 {
|
||||||
|
|||||||
@@ -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)
|
t.Errorf("ssh: after_verdict=%d recorded=%s created=%s", a, rec, cr)
|
||||||
}
|
}
|
||||||
var ver int
|
var ver int
|
||||||
if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver != 10 {
|
if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver != 11 {
|
||||||
t.Errorf("user_version=%d err=%v", ver, err)
|
t.Errorf("user_version=%d err=%v", ver, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
|||||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 {
|
if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts)
|
t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -22,7 +23,21 @@ func (s *Server) handleAdminRegistry(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result")}
|
filter := db.RegistryFilter{Query: strings.TrimSpace(q.Get("q")), LastResult: q.Get("last_result"), Subnet: strings.TrimSpace(q.Get("subnet"))}
|
||||||
|
if filter.Subnet != "" {
|
||||||
|
if _, err := netip.ParsePrefix(filter.Subnet); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid subnet "+strconv.Quote(filter.Subnet)+" (a CIDR such as 203.0.113.0/24 is expected)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := q.Get("run"); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid run "+strconv.Quote(v))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.RunID = id
|
||||||
|
}
|
||||||
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
|
if filter.LastResult != "" && !db.IsValidResult(filter.LastResult) {
|
||||||
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
writeError(w, http.StatusBadRequest, "invalid last_result "+strconv.Quote(filter.LastResult)+" (valid: pass, partial, fail, cancelled)")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ func (s *Server) routeTable() []route {
|
|||||||
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
|
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
|
||||||
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
|
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
|
||||||
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
|
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
|
||||||
|
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin},
|
||||||
|
{"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin},
|
||||||
|
{"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin},
|
||||||
|
{"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin},
|
||||||
|
{"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, accessAdmin},
|
||||||
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
|
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
|
||||||
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
|
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
|
||||||
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
|
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Server struct {
|
|||||||
Orch *orchestrator.Orchestrator
|
Orch *orchestrator.Orchestrator
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
Auth Authenticator
|
Auth Authenticator
|
||||||
|
|
||||||
|
analytics analyticsCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ func (o *Orchestrator) Tick(ctx context.Context) {
|
|||||||
if err := o.sweepExpiredLeases(ctx); err != nil {
|
if err := o.sweepExpiredLeases(ctx); err != nil {
|
||||||
o.Log.Error("sweep expired leases", "err", err)
|
o.Log.Error("sweep expired leases", "err", err)
|
||||||
}
|
}
|
||||||
|
if err := o.DB.FinalizeRuns(ctx); err != nil {
|
||||||
|
o.Log.Error("finalize runs", "err", err)
|
||||||
|
}
|
||||||
if !o.Async {
|
if !o.Async {
|
||||||
o.wg.Wait()
|
o.wg.Wait()
|
||||||
}
|
}
|
||||||
@@ -718,7 +721,7 @@ func (o *Orchestrator) aggregateAndRelease(ctx context.Context, item db.IPQueueI
|
|||||||
result, passCount, missing := computeVerdict(checks, expected, o.Agg.MissingCountsAsFail)
|
result, passCount, missing := computeVerdict(checks, expected, o.Agg.MissingCountsAsFail)
|
||||||
egress, ingress := countByLevel(checks)
|
egress, ingress := countByLevel(checks)
|
||||||
|
|
||||||
if err := o.DB.FinishIP(ctx, item.ID, result); err != nil {
|
if err := o.DB.FinishIPExpected(ctx, item.ID, result, expected); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
o.event(ctx, "control-api", "", &item.ID, "aggregated",
|
o.event(ctx, "control-api", "", &item.ID, "aggregated",
|
||||||
|
|||||||
@@ -282,7 +282,11 @@ func (o *Orchestrator) doScan(ctx context.Context, opts ScanOptions) (scanResult
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
chunk := free[off:min(off+scanChunkSize, len(free))]
|
chunk := free[off:min(off+scanChunkSize, len(free))]
|
||||||
r, err := o.DB.SubmitIPs(ctx, chunk)
|
kind := db.RunManual
|
||||||
|
if opts.ClearFirst {
|
||||||
|
kind = db.RunAuto // the auto-cycle's scan; a manual scan never clears first
|
||||||
|
}
|
||||||
|
r, err := o.DB.SubmitIPsAs(ctx, chunk, kind)
|
||||||
res.submit.Added = append(res.submit.Added, r.Added...)
|
res.submit.Added = append(res.submit.Added, r.Added...)
|
||||||
res.submit.Requeued = append(res.submit.Requeued, r.Requeued...)
|
res.submit.Requeued = append(res.submit.Requeued, r.Requeued...)
|
||||||
res.submit.Reordered = append(res.submit.Reordered, r.Reordered...)
|
res.submit.Reordered = append(res.submit.Reordered, r.Reordered...)
|
||||||
|
|||||||
Reference in new issue
Block a user