Analytics: compare two finished runs

New page /analytics/compare and API GET /admin/analytics/compare (+ /lists/{group}):
the administrator picks an old (A) and a new (B) run; the report shows the new
addresses (only in B), the ones that left (only in A) and the common ones whose
membership in the seven indicators (pass, partial, fail, egress https any/all,
ingress ssh any/all) differs, with a "what changed" summary per address; the
dynamics of each indicator (delta = new - left + entered - exited) and a verdict
transition matrix. Every number opens a list with CSV. Cancelled addresses are not
part of a run. The list dialog moved to a shared analytics-dialog.js and template;
/analytics got a "compare with another run" button.

Docs, plan and summary in docs/changes/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-04 10:26:37 +03:00
1 parent 2f038f8362
commit 068c10ea1c
28 files changed
+2052 -133

No files matched your search

+1
View File
@@ -208,6 +208,7 @@ scripts/run-local-e2e.sh # сквозной прог
| Дата | Веха | Документ | | Дата | Веха | Документ |
|---|---|---| |---|---|---|
| 2026-10-04 | Аналитика: сравнение двух запусков (`/analytics/compare`): новые, выбывшие и изменившиеся адреса, динамика по семи индикаторам, матрица переходов вердикта; API `analytics/compare` | [план](docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md) · [итог](docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md) |
| 2026-10-04 | Аналитика: карточки `pass`, `partial`, `fail` открывают список адресов с этим вердиктом и выгрузку в CSV (`lists/verdict_*`) | [план](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-plan.md) · [итог](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-summary.md) | | 2026-10-04 | Аналитика: карточки `pass`, `partial`, `fail` открывают список адресов с этим вердиктом и выгрузку в CSV (`lists/verdict_*`) | [план](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-plan.md) · [итог](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-summary.md) |
| 2026-10-04 | Повтор после сбоя self-check — на другом валидаторе: валидатор, проваливший self-check, этому адресу больше не выдаётся; потолок провалов `self_check_max_attempts` (по умолчанию 5, миграция `0012`); поле `self_check_failed_on` | [план](docs/changes/2026-10-04_08-01_self-check-exclude-validator-plan.md) · [итог](docs/changes/2026-10-04_08-01_self-check-exclude-validator-summary.md) | | 2026-10-04 | Повтор после сбоя self-check — на другом валидаторе: валидатор, проваливший self-check, этому адресу больше не выдаётся; потолок провалов `self_check_max_attempts` (по умолчанию 5, миграция `0012`); поле `self_check_failed_on` | [план](docs/changes/2026-10-04_08-01_self-check-exclude-validator-plan.md) · [итог](docs/changes/2026-10-04_08-01_self-check-exclude-validator-summary.md) |
| 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 | Раздел «Аналитика»: запуски проверки (миграция `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#аналитика-запусков) |
+2 -2
View File
@@ -1,4 +1,4 @@
43160e29c10730592ba1b25c20b42665d043a7d25b94433b2b89d5fd111a5b36 control-api 2f4fa432060fb70c744ab5f593d3f12bbf39f5f6587b4fb6a445c60218d25831 control-api
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent 9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober 3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
bb3ca7ba12a93c5a018a5c7532dfeb722a866ddfa27a9521cb24d0464fa3595d admin-dashboard 76db6c2dedaa89b7ba8253287a8289c7d72034f5371bcc714a55f3ab257f66ff admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
+27
View File
@@ -1072,6 +1072,33 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
имя вида `ingress_ssh_all_run1.csv`). Для `verdict_*` — адреса запуска с этим вердиктом (без `cancelled`, по числовому порядку; число строк равно `summary.pass`/`partial`/`fail`): адрес, подсеть, валидатор (по https-проверкам, «—», если их нет), `Egress` и `Ingress` («успешно из всех», «—» без проверок), «Проверок в цикле» (записано из ожидаемых) и у `partial` ещё «Причина» (как в блоке `reasons`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка, имя вида `ingress_ssh_all_run1.csv`). Для `verdict_*` — адреса запуска с этим вердиктом (без `cancelled`, по числовому порядку; число строк равно `summary.pass`/`partial`/`fail`): адрес, подсеть, валидатор (по https-проверкам, «—», если их нет), `Egress` и `Ingress` («успешно из всех», «—» без проверок), «Проверок в цикле» (записано из ожидаемых) и у `partial` ещё «Причина» (как в блоке `reasons`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка,
валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»). валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»).
### `GET /api/v1/admin/analytics/compare?base=A&target=B`
Сравнение двух завершённых запусков: `base` — старый (A), `target` — новый (B). Адрес — это IP; адрес с итогом
`cancelled` в запуск не входит (их число — в `cancelled`). Ответ:
| Поле | Содержимое |
|---|---|
| `runs` | `base` и `target`: сведения о запуске и `addresses` |
| `groups` | `new` (есть в B, нет в A), `left` (были в A, нет в B), `common` (в обоих), `changed`, `same`; `common = changed + same` |
| `indicators` | по семи индикаторам (`verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all`): `base`, `target`, `delta`, `new`, `left`, `entered`, `exited`; `delta = new − left + entered − exited` |
| `transitions` | `verdicts`, `matrix[из][в]` вердиктов общих адресов, `new` и `left` — новые/выбывшие по вердикту |
| `cancelled` | отменённые адреса в `base` и `target` |
Общий адрес **изменился**, если его принадлежность хотя бы к одному из семи индикаторов в A и B разная (другой набор
проваленных целей или площадок при тех же индикаторах — не изменение). `400` — нет или неверные `base`/`target` либо
они совпадают, `404` — запуска нет, `409` — запуск ещё идёт.
### `GET /api/v1/admin/analytics/compare/lists/{group}?base=A&target=B`
Таблица адресов группы `new`, `left`, `common`, `changed`, `same`, `entered` или `exited`. Фильтры: `indicator` (ключ
индикатора; для `entered` и `exited` обязателен; для `new`/`left` — адрес входит в индикатор в своём запуске, для
`common`/`changed`/`same` — хотя бы в одном из запусков), `from` и `to` вместе (вердикт в A и в B; только для общих групп).
Неизвестные группа, индикатор или фильтр — `404`. Столбцы `new` и `left`: адрес, подсеть, вердикт, Egress, Ingress,
индикаторы. Остальные группы: адрес, подсеть, вердикт, Egress и Ingress в виде `A → B` и «Что изменилось» (вердикт,
вход в индикаторы и выход из них, добавленные и убранные цели https и площадки ssh, смена валидатора; у группы без
изменений — «без изменений»). С `?format=csv` — файл CSV (UTF-8 с BOM), имя `compare_<group>[_<indicator>][_<from>-<to>]_run<A>-<B>.csv`.
### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets` ### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets`
Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком: Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком:
+1 -1
View File
@@ -58,7 +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 по площадкам, классы ошибок, валидаторы. Карточки `pass`/`partial`/`fail`, карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). | | `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки `pass`/`partial`/`fail`, карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Кнопка «Сравнить с другим запуском» ведёт на `/analytics/compare?base=A&target=B`: сравнение двух запусков (новые, выбывшие и изменившиеся адреса, динамика по семи индикаторам, матрица переходов вердикта; числа открывают списки с 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-проверок — создание/редактирование/удаление. |
+11
View File
@@ -446,6 +446,17 @@ curl -s http://<control-api>:8080/api/v1/admin/registry/203.0.113.10 | python3 -
UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр» UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр»
с фильтром по запуску и подсети (`/registry?run=…&subnet=…`). с фильтром по запуску и подсети (`/registry?run=…&subnet=…`).
**Сравнение запусков.** Страница `/analytics/compare` (кнопка «Сравнить с другим запуском» на `/analytics`) показывает
динамику между двумя завершёнными запусками: A (старый) и B (новый) выбираются из списков, «Поменять местами» меняет
их роли; по умолчанию B — последний запуск, A — предыдущий; выбор хранится в адресе страницы. Страница выделяет три
группы адресов: **новые** (есть в B, не было в A: пришли в проект), **выбывшие** (были в A, в B нет) и **изменившиеся**
(есть в обоих, но принадлежность к индикаторам разная; в списке столбец «Что изменилось»). Таблица «Динамика по
индикаторам» даёт по каждому из семи индикаторов (`pass`, `partial`, `fail`, «Egress https» и «Ingress ssh» — «есть
провалы» и «все провалены») значения в A и B, разность и её разложение: новые, выбывшие, вошли, вышли. «Переходы
вердикта» — матрица 3×3 по общим адресам. Все числа открывают список адресов с выгрузкой в CSV. Адреса с итогом
`cancelled` в сравнение не входят. Если запуски разного состава (например, перепроверка части адресов), новых и
выбывших будет много: это состав запусков, а не динамика. API — [API.md](API.md#get-apiv1adminanalyticscomparebasea-targetb).
**Подсети.** Список задаётся на `/settings` (блок «Подсети»): по одной в строке, CIDR и, через пробел, подпись. Адрес **Подсети.** Список задаётся на `/settings` (блок «Подсети»): по одной в строке, CIDR и, через пробел, подпись. Адрес
относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса
группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`. группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`.
@@ -0,0 +1,89 @@
# План: сравнение двух запусков в разделе «Аналитика»
Статус: реализовано, см. [итог](2026-10-04_10-08_analytics-run-compare-summary.md).
## 1. Что нужно
Администратор выбирает два завершённых запуска и видит динамику между ними по семи индикаторам страницы «Аналитика»: `pass`, `partial`, `fail`, «Egress https: есть провалы», «Egress https: все провалены», «Ingress ssh: есть провалы», «Ingress ssh: все провалены». Отдельно выделяются три группы адресов:
1. **Новые**: есть в новом запуске, в старом не было. Нужны, чтобы понять, какие адреса прибыли в проект и подключились к анализу.
2. **Изменившиеся**: есть в обоих запусках, но состояние по проверкам разное. Для них показывается подробная сводка, что именно изменилось.
3. **Выбывшие**: были в старом запуске, в новом их нет. Нужны, чтобы понять, какие адреса вышли из состава проекта.
Адреса, которые есть в обоих запусках и не изменились, только считаются (список доступен, но не выделяется).
## 2. Основные решения
- **Отдельная страница `/analytics/compare`**, а не режим текущей: страница одного запуска остаётся как есть («данные других запусков на странице не участвуют»). На `/analytics` добавляется кнопка «Сравнить с другим запуском», она ведёт на сравнение с текущим запуском в роли нового.
- **Выбор запусков.** Два списка: «Запуск A (старый)» и «Запуск B (новый)» и кнопка «Поменять местами». По умолчанию B — последний завершённый запуск, A — предыдущий. Порядок не навязывается: «новые» всегда означает «есть в B, нет в A». Доступны только завершённые запуски с адресами (как в текущем выборе). Одинаковые A и B — ошибка `400`.
- **Адрес — это IP.** В запуске он присутствует, если у него есть итог, отличный от `cancelled`. Отменённые адреса из сравнения исключаются (в обоих запусках), их число показывается примечанием, чтобы они не выглядели как «новые» или «выбывшие».
- **Состояние адреса** — принадлежность к семи индикаторам по последнему циклу адреса в запуске (те же правила, что в отчёте запуска; один адрес может входить в несколько, вердикт — ровно в один из трёх). Адрес **изменился**, если принадлежность хотя бы к одному индикатору в A и B разная. Адрес с теми же индикаторами, но другим набором проваленных целей или площадок считается неизменившимся (см. риски).
- **Расчёт на control-api** по двум уже существующим `Analysis` (кэш завершённых запусков переиспользуется). Новых таблиц, миграций и новых данных нет.
## 3. Решение
### 3.1. control-api (`internal/analytics/compare.go`, новый)
- Таблица семи индикаторов (ключ, название, предикат по адресу): ключи совпадают с видами списков `verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all`. Предикаты повторяют условия `Compute`/`List`; тест проверяет, что число адресов по каждому индикатору в каждом запуске равно соответствующему полю `summary`.
- `Compare(a, b *Analysis) *Comparison`:
- группы: `new`, `left`, `common`, `changed`, `same` (числа; `common = changed + same`);
- по каждому индикатору: `base`, `target`, `delta`, `new` (новые адреса в индикаторе), `left` (выбывшие, были в индикаторе), `entered` (общие адреса, вошедшие в индикатор), `exited` (общие адреса, вышедшие из индикатора). Инвариант: `delta = new − left + entered − exited` (проверяется тестом);
- матрица переходов вердикта для общих адресов (3×3), плюс строка «нет в A» (новые) и столбец «нет в B» (выбывшие);
- число отменённых адресов в каждом запуске.
- `(*Comparison) List(group, filter)`: таблица адресов. Группы: `new`, `left`, `changed`, `same`, `entered`, `exited`. Фильтры: `indicator` (для `new`/`left` — адрес входит в индикатор в своём запуске; для `changed`/`same` — принадлежность этому индикатору; для `entered`/`exited` обязателен), `from`+`to` (вердикт в A и в B, для ячейки матрицы). Порядок — по числовому адресу. Неверная группа или индикатор — ошибка (`404`).
- Столбцы списков:
- `new`: Адрес, Подсеть, Вердикт, Egress, Ingress, Индикаторы (в B);
- `left`: те же столбцы по запуску A;
- `changed`, `entered`, `exited`, `same`: Адрес, Подсеть, Вердикт (A → B), Egress (A → B), Ingress (A → B), **Что изменилось**. «Что изменилось» — текст по шагам: «вердикт partial → pass», «вошёл в: Egress https: все провалены», «вышел из: Ingress ssh: есть провалы», «https: провалены цели +a.test −b.test», «ssh: площадки −rxmsk», «валидатор v3 → v12». У `same` — «без изменений».
- Для текста нужны из `addr` уже имеющиеся поля (`https.failedTargets`, `ssh.sites`, `https.validator`, статистики egress/ingress); новых вычислений в `Compute` нет.
### 3.2. API (`internal/httpapi/handlers_analytics.go`)
- `GET /api/v1/admin/analytics/compare?base=A&target=B` → отчёт сравнения (`runs`: сведения об обоих запусках, `groups`, `indicators`, `transitions`, `cancelled`).
- `GET /api/v1/admin/analytics/compare/lists/{group}?base=A&target=B[&indicator=…][&from=…&to=…][&format=csv]` → таблица или CSV (UTF-8 с BOM; имя `compare_<group>[_<indicator>]_run<A>-<B>.csv`).
- Ошибки: `400` (нет или неверные id, `base = target`), `404` (запуска нет, неизвестная группа/индикатор), `409` (запуск ещё идёт).
- Загрузка `Analysis` по id выносится из `analysisFor` в общую функцию, чтобы сравнение и страница одного запуска пользовались одним кэшем.
### 3.3. Дашборд
- Страница `/analytics/compare?base=A&target=B` (`handlers_analytics.go`, `templates/analytics_compare.html`, `static/analytics-compare.js`), прокси списков и CSV: `/analytics/compare/lists/{group}` и `/analytics/compare/csv/{group}`. Состояние страницы целиком в адресе (можно отправить ссылку).
- Содержимое страницы сверху вниз:
1. Выбор A и B, «Поменять местами», «Сравнить»; под ним примечание: даты, тип и размер обоих запусков, число отменённых.
2. Карточки: «Новые», «Выбывшие», «Общие», «Изменились», «Без изменений». Кликабельны, открывают диалог со списком.
3. «Динамика по индикаторам»: строка на индикатор, столбцы A, B, Δ (рост `pass` и падение остальных — зелёным, обратное — красным), Новые, Выбывшие, Вошли, Вышли. Ненулевые числа кликабельны (список с соответствующими `group` и `indicator`).
4. «Переходы вердикта»: матрица 3×3 по общим адресам + «нет в A» / «нет в B»; ячейки кликабельны.
5. Диалог со списком, «Скачать CSV», «Копировать» — тот же, что на странице запуска.
- Код диалога (открытие, заполнение, CSV, копирование, подсказки) выносится из `analytics.js` в общий `analytics-dialog.js`, разметка диалога — в общий шаблон; страница одного запуска переходит на него без изменения поведения.
- На `/analytics` добавляется кнопка «Сравнить с другим запуском».
## 4. Файлы
- `internal/analytics/compare.go` (новый), `internal/analytics/compare_test.go` (новый).
- `internal/httpapi/handlers_analytics.go` (общий загрузчик, два новых обработчика), `routes`.
- `internal/dashboard`: `handlers_analytics.go` (страница и прокси), `client.go`, `routes.go`, `templates/analytics_compare.html`, `templates/analytics.html` (кнопка, общий диалог), `static/analytics.js`, `static/analytics-dialog.js` (новый), `static/analytics-compare.js` (новый), `static/analytics.css`.
- Документы: `docs/API.md`, `docs/USAGE.md` («Аналитика запусков»), `docs/DASHBOARD.md`, `README.md`, итог `docs/changes/…-summary.md`.
## 5. Тесты
- `analytics`: два запуска с новыми, выбывшими, изменившимися и неизменившимися адресами, с отменёнными; числа групп; инвариант `delta = new − left + entered − exited` по каждому индикатору; число адресов по индикатору равно `summary` каждого запуска; матрица переходов; списки каждой группы и фильтры (`indicator`, `from`/`to`); текст «Что изменилось» (вердикт, вход/выход из индикатора, цели, площадки, валидатор); порядок по адресу; пустые группы.
- `httpapi`: отчёт и списки (JSON, CSV с именем файла), `400` на одинаковые и неверные id, `404` на неизвестные запуск/группу/индикатор, `409` на незавершённый запуск.
- `dashboard`: страница с запуском по умолчанию, с явным `base`/`target`, предупреждение на неверные id, прокси списка и CSV; страница одного запуска после выноса диалога по-прежнему отдаёт данные и скрипты.
- `gofmt -l`, `go build ./... && go vet ./... && go test ./...`, проверка синтаксиса JS.
## 6. Выкладка и проверка
Меняются `control-api` и `admin-dashboard` (статика встроена); агенты, prober, миграции без изменений. Порядок: проверка пустой очереди, тег отката образов, пересборка, перезапуск. Проверка на стенде: сравнение запусков 1 и 2 через API (сумма групп равна числу адресов, инвариант по индикаторам), затем страница в браузере: выбор запусков, обмен местами, открытие диалогов из карточек, таблицы и матрицы, CSV, а также что страница одного запуска работает как раньше.
## 7. Риски
- **Что считается изменением.** Только принадлежность к семи индикаторам. Адрес, который остался в «Egress https: есть провалы», но у которого поменялись проваленные цели, попадает в «без изменений»; в списке `same` его сводка «без изменений» это не показывает. Если такие случаи важны, следующим шагом можно считать изменением и смену наборов целей и площадок.
- **Размер списков.** Группа новых или `same` может содержать тысячи строк (диалог показывает все, как и другие списки); CSV полный.
- **Разный состав запусков.** Если запуск 1 был частичным (например, перепроверка), то в сравнении будет много «новых» и «выбывших»: это отражает состав запусков, а не динамику. Размеры обоих запусков показаны в примечании над таблицами.
- Вердикт — оценка системы при агрегации, а не итог всех проверок (см. «Качество данных»); сравнение вердикта это не меняет, столбцы Egress и Ingress показывают фактические проверки.
- Вынос кода диалога затрагивает работающую страницу; закрывается тестами и ручной проверкой п. 6.
## 8. Не входит в доработку
Сравнение более чем двух запусков, графики динамики по времени, разбивка новых и выбывших по подсетям, сравнение по другим показателям (классы ошибок, площадки, валидаторы), автоматический выбор «интересных» пар запусков.
Открытых вопросов нет. Жду команды начать реализацию.
@@ -0,0 +1,35 @@
# Итог: сравнение двух запусков в разделе «Аналитика»
План: [2026-10-04_10-08_analytics-run-compare-plan.md](2026-10-04_10-08_analytics-run-compare-plan.md).
Статус: код написан и проверен (gofmt, build, vet, test, синтаксис JS); стенд **не пересобирался**.
## Что изменено
- **control-api** (`internal/analytics/compare.go`): таблица семи индикаторов (ключи совпадают с видами списков), `Compare(base, target)` — группы `new`, `left`, `common`, `changed`, `same`, разложение каждого индикатора (`delta = new − left + entered − exited`), матрица переходов вердикта, число отменённых. `List(group, filter)` с фильтрами `indicator`, `from`/`to` и столбцом «Что изменилось».
- **API**: `GET /admin/analytics/compare` и `GET /admin/analytics/compare/lists/{group}` (JSON и CSV); `400` — неверные или одинаковые id, `404` — нет запуска, группы, индикатора или фильтра, `409` — запуск идёт. Загрузка отчёта запуска вынесена в общую функцию, кэш общий.
- **Дашборд**: страница `/analytics/compare` (выбор A и B, «Поменять местами», карточки групп, «Динамика по индикаторам», «Переходы вердикта», диалог со списком и CSV); прокси списков и CSV; кнопка «Сравнить с другим запуском» на `/analytics`.
- **Общий диалог**: код списков вынесен в `static/analytics-dialog.js`, разметка — в шаблон `analytics_dialog.html`; страница одного запуска перешла на него.
- **Документы**: `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`.
## Отклонения от плана
1. Добавлена группа `common` (карточка «Общие» и ячейки матрицы открывают список через неё).
2. Фильтр `indicator` для `changed`/`same`/`common` — «хотя бы в одном из запусков».
3. Неверные `from`/`to` дают `404`, как другие неверные виды списков; к имени CSV добавляется суффикс `_<from>-<to>`.
4. Разметка диалога вынесена в отдельный шаблон.
## Особенности
- Адрес, оставшийся в тех же индикаторах, но с другим набором проваленных целей или площадок, считается неизменившимся (`same`).
- Дубли IP внутри запуска не обрабатываются (в реестре IP уникален).
## Проверки
- `gofmt -l` пусто; `go build ./...`, `go vet ./...`, `go test ./...` — все пакеты `ok`; `node --check` для трёх JS-файлов.
- Исправлено при ревью: в тесте дашборда число с неразрывным пробелом (`6 440`); в `TestRouteTableIsClassified` число admin-маршрутов 39 → 41.
- Новые тесты: инварианты сравнения (числа индикаторов равны `summary`, разложение `delta`, сумма групп и матрицы), все группы и фильтры, тексты «Что изменилось», API (отчёт, списки, CSV, коды ошибок), дашборд (пара по умолчанию, предупреждения, прокси, страница одного запуска после выноса диалога).
- Не проверено: страницы в браузере (диалог на `/analytics`, «Поменять местами», клики по матрице) и сравнение на данных стенда.
## Выкладка
Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard`; миграций нет.
+432
View File
@@ -0,0 +1,432 @@
package analytics
import (
"sort"
"strings"
"cloudipvalidator/internal/db"
)
// indicator is one of the seven counters of the analytics page. Its key is the
// name of the list behind the counter; has repeats the condition Compute counts
// it by (an address with a cancelled result is never asked).
type indicator struct {
key, name string
has func(a *addr) bool
}
var indicators = []indicator{
{ListVerdictPass, "pass", func(a *addr) bool { return a.res.Verdict == db.ResultPass }},
{ListVerdictPartial, "partial", func(a *addr) bool { return a.res.Verdict == db.ResultPartial }},
{ListVerdictFail, "fail", func(a *addr) bool { return a.res.Verdict == db.ResultFail }},
{ListEgressHTTPSAny, "Egress https: есть провалы", func(a *addr) bool { return a.https.n > 0 && a.https.ok < a.https.n }},
{ListEgressHTTPSAll, "Egress https: все провалены", func(a *addr) bool { return a.https.n > 0 && a.https.ok == 0 }},
{ListIngressSSHAny, "Ingress ssh: есть провалы", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok < a.ssh.n }},
{ListIngressSSHAll, "Ingress ssh: все провалены", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok == 0 }},
}
// indicatorIndex is the position of an indicator key in the table, -1 if unknown.
func indicatorIndex(key string) int {
for i, ind := range indicators {
if ind.key == key {
return i
}
}
return -1
}
// verdicts are the rows and columns of the transition matrix.
var verdicts = []string{db.ResultPass, db.ResultPartial, db.ResultFail}
func verdictIndex(v string) int {
for i, x := range verdicts {
if x == v {
return i
}
}
return -1
}
// membership is the set of indicators an address belongs to, one bit per
// entry of the indicators table.
func membership(a *addr) uint8 {
var m uint8
for i, ind := range indicators {
if ind.has(a) {
m |= 1 << i
}
}
return m
}
// Comparison is the difference between two finished runs: the base (older) and
// the target (newer) one. An address is its IP; one with a cancelled result is
// not in its run.
type Comparison struct {
Runs CompareRuns `json:"runs"`
Groups CompareGroups `json:"groups"`
Indicators []IndicatorDiff `json:"indicators"`
Transitions Transitions `json:"transitions"`
Cancelled CompareCancel `json:"cancelled"`
rows []*cmpRow // numeric address order
target *Analysis
}
type CompareRuns struct {
Base CompareRun `json:"base"`
Target CompareRun `json:"target"`
}
type CompareRun struct {
RunInfo
Addresses int `json:"addresses"`
}
// CompareGroups counts the addresses by how they relate to the two runs:
// New are only in the target, Left only in the base, Common in both, and
// Common = Changed + Same.
type CompareGroups struct {
New int `json:"new"`
Left int `json:"left"`
Common int `json:"common"`
Changed int `json:"changed"`
Same int `json:"same"`
}
// IndicatorDiff is one indicator in both runs. Delta = Target - Base =
// New - Left + Entered - Exited.
type IndicatorDiff struct {
Key string `json:"key"`
Name string `json:"name"`
Base int `json:"base"`
Target int `json:"target"`
Delta int `json:"delta"`
New int `json:"new"` // new addresses that are in the indicator
Left int `json:"left"` // left addresses that were in it
Entered int `json:"entered"` // common addresses that entered it
Exited int `json:"exited"` // common addresses that left it
}
// Transitions is the verdict of the common addresses: Matrix[from][to] with
// the verdicts of the base on the rows and of the target on the columns. New
// holds the new addresses by their verdict in the target, Left the addresses
// that left by their verdict in the base.
type Transitions struct {
Verdicts []string `json:"verdicts"`
Matrix [][]int `json:"matrix"`
New []int `json:"new"`
Left []int `json:"left"`
}
type CompareCancel struct {
Base int `json:"base"`
Target int `json:"target"`
}
// cmpRow is one address with its state in each run (nil when absent) and the
// indicators it belongs to there.
type cmpRow struct {
ip string
a, b *addr
ma, mb uint8
}
func (r *cmpRow) common() bool { return r.a != nil && r.b != nil }
func (r *cmpRow) changed() bool { return r.common() && r.ma != r.mb }
// Compare puts two analyses side by side; base is the older run, target the newer.
func Compare(base, target *Analysis) *Comparison {
byIP := map[string]*cmpRow{}
var rows []*cmpRow
add := func(list []*addr, isBase bool) {
for _, x := range list {
r := byIP[x.res.IPAddress]
if r == nil {
r = &cmpRow{ip: x.res.IPAddress}
byIP[r.ip] = r
rows = append(rows, r)
}
if isBase {
r.a, r.ma = x, membership(x)
} else {
r.b, r.mb = x, membership(x)
}
}
}
add(base.sorted(), true)
add(target.sorted(), false)
sort.Slice(rows, func(i, j int) bool { return lessIP(rows[i].ip, rows[j].ip) })
c := &Comparison{rows: rows, target: target}
c.Runs = CompareRuns{
Base: CompareRun{RunInfo: base.Report.Run, Addresses: base.Report.Summary.Addresses},
Target: CompareRun{RunInfo: target.Report.Run, Addresses: target.Report.Summary.Addresses},
}
c.Cancelled = CompareCancel{Base: base.Report.Summary.Cancelled, Target: target.Report.Summary.Cancelled}
c.Indicators = make([]IndicatorDiff, len(indicators))
for i, ind := range indicators {
c.Indicators[i] = IndicatorDiff{Key: ind.key, Name: ind.name}
}
tr := &c.Transitions
tr.Verdicts = verdicts
tr.New, tr.Left = make([]int, len(verdicts)), make([]int, len(verdicts))
tr.Matrix = make([][]int, len(verdicts))
for i := range tr.Matrix {
tr.Matrix[i] = make([]int, len(verdicts))
}
for _, r := range rows {
switch {
case r.a == nil:
c.Groups.New++
if v := verdictIndex(r.b.res.Verdict); v >= 0 {
tr.New[v]++
}
case r.b == nil:
c.Groups.Left++
if v := verdictIndex(r.a.res.Verdict); v >= 0 {
tr.Left[v]++
}
default:
c.Groups.Common++
if r.changed() {
c.Groups.Changed++
} else {
c.Groups.Same++
}
if from, to := verdictIndex(r.a.res.Verdict), verdictIndex(r.b.res.Verdict); from >= 0 && to >= 0 {
tr.Matrix[from][to]++
}
}
for i := range indicators {
bit := uint8(1) << i
inA, inB := r.ma&bit != 0, r.mb&bit != 0
d := &c.Indicators[i]
if inA {
d.Base++
}
if inB {
d.Target++
}
switch {
case r.a == nil && inB:
d.New++
case r.b == nil && inA:
d.Left++
case r.common() && !inA && inB:
d.Entered++
case r.common() && inA && !inB:
d.Exited++
}
}
}
for i := range c.Indicators {
c.Indicators[i].Delta = c.Indicators[i].Target - c.Indicators[i].Base
}
return c
}
// Groups served by Comparison.List.
const (
GroupNew = "new"
GroupLeft = "left"
GroupCommon = "common"
GroupChanged = "changed"
GroupSame = "same"
GroupEntered = "entered"
GroupExited = "exited"
)
// CompareFilter narrows a group. Indicator is a list key of the indicator
// table: for new and left the address is in it in its own run, for common,
// changed and same in either run, for entered and exited it is required.
// From and To, only together, keep the common addresses whose verdict was From
// in the base and is To in the target.
type CompareFilter struct {
Indicator string
From, To string
}
// CompareList is a table of addresses of one group.
type CompareList struct {
Group string `json:"group"`
Indicator string `json:"indicator,omitempty"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
// List builds the table of a group; an unknown group, indicator or verdict, or
// a filter that does not fit the group, is an ErrUnknownList.
func (c *Comparison) List(group string, f CompareFilter) (*CompareList, error) {
switch group {
case GroupNew, GroupLeft, GroupCommon, GroupChanged, GroupSame, GroupEntered, GroupExited:
default:
return nil, ErrUnknownList(group)
}
var bit uint8
if f.Indicator != "" {
i := indicatorIndex(f.Indicator)
if i < 0 {
return nil, ErrUnknownList("indicator " + f.Indicator)
}
bit = 1 << i
}
if bit == 0 && (group == GroupEntered || group == GroupExited) {
return nil, ErrUnknownList(group + " without indicator")
}
if (f.From != "") != (f.To != "") {
return nil, ErrUnknownList("from without to")
}
if f.From != "" {
if verdictIndex(f.From) < 0 || verdictIndex(f.To) < 0 {
return nil, ErrUnknownList("verdict " + f.From + " → " + f.To)
}
if group == GroupNew || group == GroupLeft {
return nil, ErrUnknownList("from and to for " + group)
}
}
l := &CompareList{Group: group, Indicator: f.Indicator, Rows: [][]string{}}
if group == GroupNew || group == GroupLeft {
l.Columns = []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}
} else {
l.Columns = []string{"Адрес", "Подсеть", "Вердикт (A → B)", "Egress (A → B)", "Ingress (A → B)", "Что изменилось"}
}
for _, r := range c.rows {
var ok bool
switch group {
case GroupNew:
ok = r.a == nil && (bit == 0 || r.mb&bit != 0)
case GroupLeft:
ok = r.b == nil && (bit == 0 || r.ma&bit != 0)
case GroupCommon:
ok = r.common()
case GroupChanged:
ok = r.changed()
case GroupSame:
ok = r.common() && !r.changed()
case GroupEntered:
ok = r.common() && r.ma&bit == 0 && r.mb&bit != 0
case GroupExited:
ok = r.common() && r.ma&bit != 0 && r.mb&bit == 0
}
if !ok {
continue
}
if r.common() && bit != 0 && group != GroupEntered && group != GroupExited && (r.ma|r.mb)&bit == 0 {
continue
}
if f.From != "" && (r.a.res.Verdict != f.From || r.b.res.Verdict != f.To) {
continue
}
switch {
case r.a == nil:
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, r.b.res.Verdict, okOf(r.b.egress), okOf(r.b.ingress), indicatorsCell(r.mb)})
case r.b == nil:
l.Rows = append(l.Rows, []string{r.ip, r.a.subnet, r.a.res.Verdict, okOf(r.a.egress), okOf(r.a.ingress), indicatorsCell(r.ma)})
default:
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, arrow(r.a.res.Verdict, r.b.res.Verdict),
arrow(okOf(r.a.egress), okOf(r.b.egress)), arrow(okOf(r.a.ingress), okOf(r.b.ingress)), c.changeText(r)})
}
}
return l, nil
}
func arrow(from, to string) string { return from + " → " + to }
// indicatorsCell names the indicators of a membership set, "—" for none.
func indicatorsCell(m uint8) string {
var names []string
for i, ind := range indicators {
if m&(1<<i) != 0 {
names = append(names, ind.name)
}
}
if len(names) == 0 {
return "—"
}
return strings.Join(names, ", ")
}
// changeText says step by step what differs between the two runs for a common
// address. An address with the same indicators is "без изменений" even if its
// failed targets or sites differ.
func (c *Comparison) changeText(r *cmpRow) string {
if r.ma == r.mb {
return "без изменений"
}
var steps []string
if r.a.res.Verdict != r.b.res.Verdict {
steps = append(steps, "вердикт "+arrow(r.a.res.Verdict, r.b.res.Verdict))
}
var in, out []string
for i, ind := range indicators {
if strings.HasPrefix(ind.key, "verdict_") {
continue // the verdict step says it
}
bit := uint8(1) << i
switch {
case r.ma&bit == 0 && r.mb&bit != 0:
in = append(in, ind.name)
case r.ma&bit != 0 && r.mb&bit == 0:
out = append(out, ind.name)
}
}
if len(in) > 0 {
steps = append(steps, "вошёл в: "+strings.Join(in, ", "))
}
if len(out) > 0 {
steps = append(steps, "вышел из: "+strings.Join(out, ", "))
}
if added, removed := setDiff(r.a.https.failedTargets, r.b.https.failedTargets, sortedStrings); len(added)+len(removed) > 0 {
steps = append(steps, "https: провалены цели "+signed(added, removed))
}
if added, removed := setDiff(r.a.ssh.sites, r.b.ssh.sites, c.target.sortSites); len(added)+len(removed) > 0 {
steps = append(steps, "ssh: площадки "+signed(added, removed))
}
if was, now := r.a.https.validator, r.b.https.validator; was != "" && now != "" && was != now {
steps = append(steps, "валидатор "+arrow(ShortValidator(was), ShortValidator(now)))
}
return strings.Join(steps, "; ")
}
// setDiff is what is in now and not in was, and the reverse, each ordered by order.
func setDiff(was, now []string, order func([]string) []string) (added, removed []string) {
in := func(list []string) map[string]bool {
m := make(map[string]bool, len(list))
for _, s := range list {
m[s] = true
}
return m
}
w, n := in(was), in(now)
for s := range n {
if !w[s] {
added = append(added, s)
}
}
for s := range w {
if !n[s] {
removed = append(removed, s)
}
}
return order(added), order(removed)
}
func sortedStrings(s []string) []string {
sort.Strings(s)
return s
}
// signed writes a set difference as "+new1 +new2 −gone1".
func signed(added, removed []string) string {
parts := make([]string, 0, len(added)+len(removed))
for _, s := range added {
parts = append(parts, "+"+s)
}
for _, s := range removed {
parts = append(parts, "−"+s)
}
return strings.Join(parts, " ")
}
+335
View File
@@ -0,0 +1,335 @@
package analytics
import (
"reflect"
"testing"
"cloudipvalidator/internal/db"
)
// set stores the four checks of an address: https to a.test and b.test, ssh
// from the two sites.
func (f *fixture) set(reg int64, validator string, a, b, ssh1, ssh2 bool) {
f.check(reg, eg, "https", "https://a.test", a, validator, "", false)
f.check(reg, eg, "https", "https://b.test", b, validator, "", false)
f.check(reg, s1, "ssh", "ip", ssh1, validator, "dial tcp: i/o timeout", false)
f.check(reg, s2, "ssh", "ip", ssh2, validator, "dial tcp: i/o timeout", false)
}
// comparedRuns builds the older and the newer run:
//
// 10.0.0.1 pass -> pass, nothing changed
// 10.0.0.2 partial -> pass: https recovered on both targets, another validator
// 10.0.0.3 pass -> partial: ssh fails from rxmsk
// 10.0.0.20 partial -> partial: another https target fails, same indicators
// 10.0.0.10 only in the old run (fail)
// 10.0.0.14 only in the old run (pass), cancelled in the new one
// 10.0.0.11 only in the new run (partial, https fails everywhere)
// 10.0.0.12 only in the new run (pass)
// 10.0.0.13 only in the new run (pass), cancelled in the old one
func comparedRuns(t *testing.T) (base, target *Analysis) {
t.Helper()
fa, fb := &fixture{}, &fixture{}
fa.addr(1, "10.0.0.1", db.ResultPass, 4)
fa.set(1, "vkiplab-v1", true, true, true, true)
fa.addr(2, "10.0.0.2", db.ResultPartial, 4)
fa.set(2, "vkiplab-v2", false, false, true, true)
fa.addr(3, "10.0.0.3", db.ResultPass, 4)
fa.set(3, "vkiplab-v3", true, true, true, true)
fa.addr(4, "10.0.0.20", db.ResultPartial, 4)
fa.set(4, "vkiplab-v4", false, true, true, true)
fa.addr(5, "10.0.0.10", db.ResultFail, 4)
fa.set(5, "vkiplab-v5", false, false, false, false)
fa.addr(6, "10.0.0.13", db.ResultCancelled, 4)
fa.set(6, "vkiplab-v6", true, true, true, true)
fa.addr(7, "10.0.0.14", db.ResultPass, 4)
fa.set(7, "vkiplab-v7", true, true, true, true)
fb.addr(1, "10.0.0.1", db.ResultPass, 4)
fb.set(1, "vkiplab-v1", true, true, true, true)
fb.addr(2, "10.0.0.2", db.ResultPass, 4)
fb.set(2, "vkiplab-v5", true, true, true, true)
fb.addr(3, "10.0.0.3", db.ResultPartial, 4)
fb.set(3, "vkiplab-v3", true, true, false, true)
fb.addr(4, "10.0.0.20", db.ResultPartial, 4)
fb.set(4, "vkiplab-v4", true, false, true, true)
fb.addr(8, "10.0.0.11", db.ResultPartial, 4)
fb.set(8, "vkiplab-v6", false, false, true, true)
fb.addr(9, "10.0.0.12", db.ResultPass, 4)
fb.set(9, "vkiplab-v6", true, true, true, true)
fb.addr(10, "10.0.0.13", db.ResultPass, 4)
fb.set(10, "vkiplab-v6", true, true, true, true)
fb.addr(11, "10.0.0.14", db.ResultCancelled, 4)
fb.set(11, "vkiplab-v7", true, true, true, true)
return fa.compute(t, nil), fb.compute(t, nil)
}
func ips(l *CompareList) []string {
out := []string{}
for _, r := range l.Rows {
out = append(out, r[0])
}
return out
}
func mustList(t *testing.T, c *Comparison, group string, f CompareFilter) *CompareList {
t.Helper()
l, err := c.List(group, f)
if err != nil {
t.Fatalf("%s %+v: %v", group, f, err)
}
for _, r := range l.Rows {
if len(r) != len(l.Columns) {
t.Errorf("%s: row %v for columns %v", group, r, l.Columns)
}
}
return l
}
func TestCompareGroupsAndIndicators(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
if want := (CompareGroups{New: 3, Left: 2, Common: 4, Changed: 2, Same: 2}); c.Groups != want {
t.Errorf("groups = %+v, want %+v", c.Groups, want)
}
// Every address is in exactly one of new, left, common; common = changed + same.
if c.Groups.New+c.Groups.Common != target.Report.Summary.Addresses || c.Groups.Left+c.Groups.Common != base.Report.Summary.Addresses ||
c.Groups.Changed+c.Groups.Same != c.Groups.Common {
t.Errorf("groups do not add up: %+v, runs %d and %d addresses", c.Groups, base.Report.Summary.Addresses, target.Report.Summary.Addresses)
}
if c.Cancelled != (CompareCancel{Base: 1, Target: 1}) {
t.Errorf("cancelled = %+v", c.Cancelled)
}
if c.Runs.Base.Addresses != 6 || c.Runs.Target.Addresses != 7 || c.Runs.Base.ID != 7 {
t.Errorf("runs = %+v", c.Runs)
}
// The count of each indicator in each run is the number of its card.
sa, sb := base.Report.Summary, target.Report.Summary
cards := map[string][2]int{
ListVerdictPass: {sa.Pass, sb.Pass}, ListVerdictPartial: {sa.Partial, sb.Partial}, ListVerdictFail: {sa.Fail, sb.Fail},
ListEgressHTTPSAny: {sa.EgressHTTPSAny, sb.EgressHTTPSAny}, ListEgressHTTPSAll: {sa.EgressHTTPSAll, sb.EgressHTTPSAll},
ListIngressSSHAny: {sa.IngressSSHAny, sb.IngressSSHAny}, ListIngressSSHAll: {sa.IngressSSHAll, sb.IngressSSHAll},
}
if len(c.Indicators) != len(cards) {
t.Fatalf("%d indicators", len(c.Indicators))
}
for _, d := range c.Indicators {
if want := cards[d.Key]; d.Base != want[0] || d.Target != want[1] {
t.Errorf("%s: %d -> %d, summaries say %v", d.Key, d.Base, d.Target, want)
}
if d.Delta != d.Target-d.Base || d.Delta != d.New-d.Left+d.Entered-d.Exited {
t.Errorf("%s: delta %d, new %d left %d entered %d exited %d", d.Key, d.Delta, d.New, d.Left, d.Entered, d.Exited)
}
}
want := map[string]IndicatorDiff{
ListVerdictPass: {Base: 3, Target: 4, Delta: 1, New: 2, Left: 1, Entered: 1, Exited: 1},
ListVerdictPartial: {Base: 2, Target: 3, Delta: 1, New: 1, Entered: 1, Exited: 1},
ListVerdictFail: {Base: 1, Target: 0, Delta: -1, Left: 1},
ListEgressHTTPSAny: {Base: 3, Target: 2, Delta: -1, New: 1, Left: 1, Exited: 1},
ListEgressHTTPSAll: {Base: 2, Target: 1, Delta: -1, New: 1, Left: 1, Exited: 1},
ListIngressSSHAny: {Base: 1, Target: 1, Left: 1, Entered: 1},
ListIngressSSHAll: {Base: 1, Target: 0, Delta: -1, Left: 1},
}
for _, d := range c.Indicators {
w := want[d.Key]
w.Key, w.Name = d.Key, d.Name
if d != w || d.Name == "" {
t.Errorf("%s = %+v, want %+v", d.Key, d, w)
}
}
// Verdicts of the common addresses, and of the new and of the left ones.
tr := c.Transitions
if !reflect.DeepEqual(tr.Verdicts, []string{"pass", "partial", "fail"}) ||
!reflect.DeepEqual(tr.Matrix, [][]int{{1, 1, 0}, {1, 1, 0}, {0, 0, 0}}) ||
!reflect.DeepEqual(tr.New, []int{2, 1, 0}) || !reflect.DeepEqual(tr.Left, []int{1, 0, 1}) {
t.Errorf("transitions = %+v", tr)
}
}
func TestCompareLists(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
// New and left: the state in the run the address is in; numeric order.
l := mustList(t, c, GroupNew, CompareFilter{})
if want := []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}; !reflect.DeepEqual(l.Columns, want) {
t.Errorf("new columns: %v", l.Columns)
}
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.11", "10.0.0.12", "10.0.0.13"}) {
t.Errorf("new: %v", got)
}
if want := []string{"10.0.0.11", "10.0.0.0/24", "partial", "0 из 2", "2 из 2", "partial, Egress https: есть провалы, Egress https: все провалены"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("new row: %v", l.Rows[0])
}
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.12", "10.0.0.13"}) {
t.Errorf("new, pass: %v", got)
}
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.11"}) {
t.Errorf("new, https all: %v", got)
}
l = mustList(t, c, GroupLeft, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.10", "10.0.0.14"}) {
t.Errorf("left: %v", got)
}
if want := "fail"; l.Rows[0][2] != want || l.Rows[0][5] != "fail, Egress https: есть провалы, Egress https: все провалены, Ingress ssh: есть провалы, Ingress ssh: все провалены" {
t.Errorf("left row: %v", l.Rows[0])
}
if got := ips(mustList(t, c, GroupLeft, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.14"}) {
t.Errorf("left, pass: %v", got)
}
// Changed: what changed, step by step.
l = mustList(t, c, GroupChanged, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.2", "10.0.0.3"}) {
t.Fatalf("changed: %v", got)
}
if l.Columns[2] != "Вердикт (A → B)" || l.Columns[5] != "Что изменилось" {
t.Errorf("changed columns: %v", l.Columns)
}
if want := []string{"10.0.0.2", "10.0.0.0/24", "partial → pass", "0 из 2 → 2 из 2", "2 из 2 → 2 из 2",
"вердикт partial → pass; вышел из: Egress https: есть провалы, Egress https: все провалены; https: провалены цели −a.test −b.test; валидатор v2 → v5"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("changed row 1: %v", l.Rows[0])
}
if want := []string{"10.0.0.3", "10.0.0.0/24", "pass → partial", "2 из 2 → 2 из 2", "2 из 2 → 1 из 2",
"вердикт pass → partial; вошёл в: Ingress ssh: есть провалы; ssh: площадки +rxmsk"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("changed row 2: %v", l.Rows[1])
}
// A filter by indicator keeps the addresses that are in it in either run.
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListIngressSSHAny})); !reflect.DeepEqual(got, []string{"10.0.0.3"}) {
t.Errorf("changed, ssh any: %v", got)
}
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.2"}) {
t.Errorf("changed, https all: %v", got)
}
// Same: another failed target does not change the indicators; numeric order (20 after 1).
l = mustList(t, c, GroupSame, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.20"}) {
t.Fatalf("same: %v", got)
}
if want := []string{"10.0.0.20", "10.0.0.0/24", "partial → partial", "1 из 2 → 1 из 2", "2 из 2 → 2 из 2", "без изменений"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("same row: %v", l.Rows[1])
}
if got := ips(mustList(t, c, GroupSame, CompareFilter{Indicator: ListEgressHTTPSAny})); !reflect.DeepEqual(got, []string{"10.0.0.20"}) {
t.Errorf("same, https any: %v", got)
}
// Common is changed + same, in address order.
l = mustList(t, c, GroupCommon, CompareFilter{})
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.20"}) || l.Rows[3][5] != "без изменений" {
t.Errorf("common: %v", l.Rows)
}
// Entered and exited need an indicator.
for _, x := range []struct {
group, ind string
want []string
}{
{GroupEntered, ListVerdictPass, []string{"10.0.0.2"}},
{GroupExited, ListVerdictPass, []string{"10.0.0.3"}},
{GroupEntered, ListVerdictPartial, []string{"10.0.0.3"}},
{GroupEntered, ListIngressSSHAny, []string{"10.0.0.3"}},
{GroupExited, ListEgressHTTPSAll, []string{"10.0.0.2"}},
{GroupExited, ListEgressHTTPSAny, []string{"10.0.0.2"}},
{GroupEntered, ListEgressHTTPSAny, []string{}},
{GroupEntered, ListVerdictFail, []string{}},
} {
got := ips(mustList(t, c, x.group, CompareFilter{Indicator: x.ind}))
if !reflect.DeepEqual(got, x.want) {
t.Errorf("%s %s: %v, want %v", x.group, x.ind, got, x.want)
}
}
// A cell of the transition matrix: the verdict in the old and in the new run.
for _, x := range []struct {
group, from, to string
want []string
}{
{GroupCommon, "partial", "pass", []string{"10.0.0.2"}},
{GroupCommon, "pass", "partial", []string{"10.0.0.3"}},
{GroupCommon, "pass", "pass", []string{"10.0.0.1"}},
{GroupCommon, "partial", "partial", []string{"10.0.0.20"}},
{GroupSame, "partial", "partial", []string{"10.0.0.20"}},
{GroupChanged, "pass", "pass", []string{}},
{GroupCommon, "fail", "pass", []string{}},
} {
got := ips(mustList(t, c, x.group, CompareFilter{From: x.from, To: x.to}))
if !reflect.DeepEqual(got, x.want) {
t.Errorf("%s %s -> %s: %v, want %v", x.group, x.from, x.to, got, x.want)
}
}
// The cells of the matrix and the lists add up.
n := 0
for _, from := range verdicts {
for _, to := range verdicts {
n += len(mustList(t, c, GroupCommon, CompareFilter{From: from, To: to}).Rows)
}
}
if n != c.Groups.Common {
t.Errorf("matrix cells hold %d addresses, %d are common", n, c.Groups.Common)
}
// The size of every list equals its number in the report.
for _, d := range c.Indicators {
for group, want := range map[string]int{GroupNew: d.New, GroupLeft: d.Left, GroupEntered: d.Entered, GroupExited: d.Exited} {
if got := len(mustList(t, c, group, CompareFilter{Indicator: d.Key}).Rows); got != want {
t.Errorf("%s %s: %d rows, report says %d", group, d.Key, got, want)
}
}
}
for group, want := range map[string]int{GroupNew: c.Groups.New, GroupLeft: c.Groups.Left, GroupCommon: c.Groups.Common, GroupChanged: c.Groups.Changed, GroupSame: c.Groups.Same} {
if got := len(mustList(t, c, group, CompareFilter{}).Rows); got != want {
t.Errorf("%s: %d rows, report says %d", group, got, want)
}
}
}
func TestCompareListErrors(t *testing.T) {
base, target := comparedRuns(t)
c := Compare(base, target)
for name, x := range map[string]struct {
group string
f CompareFilter
}{
"unknown group": {"nonsense", CompareFilter{}},
"unknown indicator": {GroupNew, CompareFilter{Indicator: "nonsense"}},
"entered needs one": {GroupEntered, CompareFilter{}},
"exited needs one": {GroupExited, CompareFilter{}},
"from without to": {GroupCommon, CompareFilter{From: "pass"}},
"to without from": {GroupCommon, CompareFilter{To: "pass"}},
"unknown verdict": {GroupCommon, CompareFilter{From: "pass", To: "cancelled"}},
"verdicts of new addrs": {GroupNew, CompareFilter{From: "pass", To: "pass"}},
"verdicts of left addrs": {GroupLeft, CompareFilter{From: "pass", To: "pass"}},
} {
l, err := c.List(x.group, x.f)
if _, ok := err.(ErrUnknownList); !ok || l != nil {
t.Errorf("%s: %v %v", name, l, err)
}
}
}
// A run compared with itself: nothing new, nothing left, nothing changed, and
// the empty lists are empty tables, not nil.
func TestCompareWithItself(t *testing.T) {
base, _ := comparedRuns(t)
c := Compare(base, base)
if want := (CompareGroups{Common: 6, Same: 6}); c.Groups != want {
t.Errorf("groups = %+v", c.Groups)
}
for _, d := range c.Indicators {
if d.Delta != 0 || d.New != 0 || d.Left != 0 || d.Entered != 0 || d.Exited != 0 || d.Base != d.Target {
t.Errorf("%+v", d)
}
}
for _, group := range []string{GroupNew, GroupLeft, GroupChanged} {
if l := mustList(t, c, group, CompareFilter{}); l.Rows == nil || len(l.Rows) != 0 {
t.Errorf("%s: %#v", group, l.Rows)
}
}
if !reflect.DeepEqual(c.Transitions.Matrix, [][]int{{3, 0, 0}, {0, 2, 0}, {0, 0, 1}}) {
t.Errorf("matrix = %v", c.Transitions.Matrix)
}
}
+9 -6
View File
@@ -149,15 +149,18 @@ func (an *Analysis) sorted() []*addr {
out = append(out, a) out = append(out, a)
} }
} }
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool { return lessIP(out[i].res.IPAddress, out[j].res.IPAddress) })
x, errX := netip.ParseAddr(out[i].res.IPAddress) return out
y, errY := netip.ParseAddr(out[j].res.IPAddress) }
// lessIP orders addresses numerically; text that is not an address by its text.
func lessIP(a, b string) bool {
x, errX := netip.ParseAddr(a)
y, errY := netip.ParseAddr(b)
if errX != nil || errY != nil { if errX != nil || errY != nil {
return out[i].res.IPAddress < out[j].res.IPAddress return a < b
} }
return x.Less(y) return x.Less(y)
})
return out
} }
func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) } func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) }
+48 -1
View File
@@ -456,7 +456,11 @@ func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class
// GetAnalyticsListCSV returns the CSV file of one address table, with the // GetAnalyticsListCSV returns the CSV file of one address table, with the
// file name control-api proposed. // file name control-api proposed.
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) { 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) return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
}
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("build request: %w", err) return nil, "", fmt.Errorf("build request: %w", err)
} }
@@ -480,6 +484,49 @@ func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, cla
return body, resp.Header.Get("Content-Disposition"), nil return body, resp.Header.Get("Content-Disposition"), nil
} }
// compareFilter narrows one list of the comparison of two runs.
type compareFilter struct{ Indicator, From, To string }
func analyticsCompareQuery(base, target int64) url.Values {
return url.Values{"base": {strconv.FormatInt(base, 10)}, "target": {strconv.FormatInt(target, 10)}}
}
func analyticsCompareListPath(base, target int64, group string, f compareFilter, csv bool) string {
v := analyticsCompareQuery(base, target)
if f.Indicator != "" {
v.Set("indicator", f.Indicator)
}
if f.From != "" || f.To != "" {
v.Set("from", f.From)
v.Set("to", f.To)
}
if csv {
v.Set("format", "csv")
}
return "/api/v1/admin/analytics/compare/lists/" + url.PathEscape(group) + "?" + v.Encode()
}
// GetAnalyticsCompare returns the comparison of two finished runs (base is the
// older one) as the raw JSON control-api computed.
func (c *client) GetAnalyticsCompare(ctx context.Context, base, target int64) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/compare?"+analyticsCompareQuery(base, target).Encode(), nil, &out)
return out, err
}
// GetAnalyticsCompareList returns one address table (JSON) of the comparison.
func (c *client) GetAnalyticsCompareList(ctx context.Context, base, target int64, group string, f compareFilter) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodGet, analyticsCompareListPath(base, target, group, f, false), nil, &out)
return out, err
}
// GetAnalyticsCompareListCSV returns the CSV file of one comparison table,
// with the file name control-api proposed.
func (c *client) GetAnalyticsCompareListCSV(ctx context.Context, base, target int64, group string, f compareFilter) ([]byte, string, error) {
return c.getCSV(ctx, analyticsCompareListPath(base, target, group, f, true))
}
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets). // subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
type subnetEntry struct { type subnetEntry struct {
CIDR string `json:"cidr"` CIDR string `json:"cidr"`
+40
View File
@@ -47,6 +47,11 @@ type fakeControlAPI struct {
runs []analyticsRun runs []analyticsRun
reports map[int64]string reports map[int64]string
lists map[string]string lists map[string]string
// compares is the comparison JSON per "base-target", compareLists the lists
// per "base-target/group[/indicator]".
compares map[string]string
compareLists map[string]string
subnets subnetList subnets subnetList
analyticsReqs []string analyticsReqs []string
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it // scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
@@ -560,6 +565,41 @@ func (f *fakeControlAPI) handler() http.Handler {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(l)) _, _ = w.Write([]byte(l))
}) })
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
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/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
q := r.URL.Query()
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
if ind := q.Get("indicator"); ind != "" {
key += "/" + ind
}
l, ok := f.compareLists[key]
if !ok {
writeAPIErr(w, http.StatusNotFound, "unknown list")
return
}
if q.Get("format") == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.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()
+205
View File
@@ -182,6 +182,211 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body) _, _ = w.Write(body)
} }
// analyticsComparePageData is the page that compares two finished runs.
type analyticsComparePageData struct {
PageData
RunsA, RunsB []runOption // finished runs; the chosen one of each is selected
HasReport bool
// DataJSON is the page's data for analytics-compare.js (run meta, the
// comparison), HTML-safe JSON.
DataJSON template.JS
}
// compareRunMeta is how analytics-compare.js names one of the two runs.
type compareRunMeta struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Start string `json:"start"`
End string `json:"end"`
Duration string `json:"duration"`
Label string `json:"label"`
}
type compareMeta struct {
Base compareRunMeta `json:"base"`
Target compareRunMeta `json:"target"`
ListURL string `json:"list_url"`
CSVURL string `json:"csv_url"`
}
func newCompareRunMeta(x analyticsRun) compareRunMeta {
m := compareRunMeta{ID: x.ID, Kind: kindLabel(x.Kind), Start: fmtShort(x.StartedAt), End: "—", Duration: "—", Label: runLabel(x)}
if x.FinalizedAt != nil {
m.End = fmtShort(*x.FinalizedAt)
m.Duration = fmtRunDuration(x.FinalizedAt.Sub(x.StartedAt))
}
return m
}
// runIDParam reads a run id from a query value: set is false for an empty
// value, ok for a positive number.
func runIDParam(v string) (id int64, set, ok bool) {
if v == "" {
return 0, false, true
}
id, err := strconv.ParseInt(v, 10, 64)
return id, true, err == nil && id > 0
}
// handleAnalyticsComparePage compares two finished runs: ?base=A (older) and
// ?target=B (newer). By default B is the newest finished run and A the one
// before it; with only target the run before it is the base, with only base
// the newest other run is the target. The state of the page is its address.
func (s *Server) handleAnalyticsComparePage(w http.ResponseWriter, r *http.Request) {
data := analyticsComparePageData{}
data.ActiveNav = "analytics"
runs, err := s.CA.ListAnalyticsRuns(r.Context())
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
var finished []analyticsRun // newest first
for _, x := range runs {
if x.State == "finalized" && x.Addresses > 0 {
finished = append(finished, x)
}
}
if len(finished) < 2 {
data.Banner = bannerData{Message: "Для сравнения нужно минимум два завершённых запуска с адресами.", Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
find := func(id int64) *analyticsRun {
for i := range finished {
if finished[i].ID == id {
return &finished[i]
}
}
return nil
}
bid, bset, bok := runIDParam(r.URL.Query().Get("base"))
tid, tset, tok := runIDParam(r.URL.Query().Get("target"))
// The default pair is the newest run as B and the one before it as A.
a, b := &finished[1], &finished[0]
warn := ""
switch {
case !bok || !tok:
warn = "Неверный номер запуска."
case bset && tset && bid == tid:
warn = "Выберите два разных запуска."
case bset || tset:
a, b = nil, nil
if bset {
if a = find(bid); a == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", bid)
}
}
if tset {
if b = find(tid); b == nil {
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", tid)
}
}
if warn == "" {
for i := range finished { // only one end is given: the other is next to it
switch {
case a == nil && i > 0 && finished[i-1].ID == b.ID:
a = &finished[i]
case b == nil && finished[i].ID != a.ID:
b = &finished[i]
}
}
if a == nil || b == nil {
warn = "Для этого запуска нет второго запуска для сравнения."
}
}
}
selA, selB := finished[1].ID, finished[0].ID
if a != nil {
selA = a.ID
}
if b != nil {
selB = b.ID
}
for _, x := range finished {
opt := runOption{ID: x.ID, Label: runLabel(x)}
optA, optB := opt, opt
optA.Selected, optB.Selected = x.ID == selA, x.ID == selB
data.RunsA = append(data.RunsA, optA)
data.RunsB = append(data.RunsB, optB)
}
if warn != "" {
data.Banner = bannerData{Message: warn, Client: true}
s.renderPage(w, r, "analytics_compare_page", data)
return
}
report, err := s.CA.GetAnalyticsCompare(r.Context(), a.ID, b.ID)
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
payload, err := json.Marshal(struct {
Meta compareMeta `json:"meta"`
Report json.RawMessage `json:"report"`
}{compareMeta{Base: newCompareRunMeta(*a), Target: newCompareRunMeta(*b),
ListURL: "/analytics/compare/lists/", CSVURL: "/analytics/compare/csv/"}, report})
if err != nil {
data.Banner = bannerFor(err)
s.renderPage(w, r, "analytics_compare_page", data)
return
}
data.HasReport = true
data.DataJSON = template.JS(payload)
s.renderPage(w, r, "analytics_compare_page", data)
}
// compareParams reads the two run ids and the list filter of a proxied
// comparison list; it writes the 400 itself when an id is missing.
func compareParams(w http.ResponseWriter, r *http.Request) (base, target int64, f compareFilter, ok bool) {
q := r.URL.Query()
base, _ = strconv.ParseInt(q.Get("base"), 10, 64)
target, _ = strconv.ParseInt(q.Get("target"), 10, 64)
if base <= 0 || target <= 0 {
http.Error(w, "base and target are required", http.StatusBadRequest)
return 0, 0, f, false
}
return base, target, compareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}, true
}
// handleAnalyticsCompareList proxies one address table of the comparison as JSON.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
out, err := s.CA.GetAnalyticsCompareList(r.Context(), base, target, r.PathValue("group"), f)
if err != nil {
writeProxyError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(out)
}
// handleAnalyticsCompareCSV proxies the CSV file of one comparison table as a download.
func (s *Server) handleAnalyticsCompareCSV(w http.ResponseWriter, r *http.Request) {
base, target, f, ok := compareParams(w, r)
if !ok {
return
}
body, disposition, err := s.CA.GetAnalyticsCompareListCSV(r.Context(), base, target, r.PathValue("group"), f)
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) { func writeProxyError(w http.ResponseWriter, err error) {
status := http.StatusBadGateway status := http.StatusBadGateway
if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 { if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 {
@@ -148,6 +148,188 @@ func TestAnalyticsListProxyAndCSV(t *testing.T) {
} }
} }
// compareWith is the minimal comparison JSON the page needs; new is a marker
// that tells the pairs of runs apart in the page source.
func compareWith(newAddrs string) string {
return `{"runs":{"base":{"id":1,"rechecked":0,"addresses":6440},"target":{"id":2,"rechecked":0,"addresses":900}},` +
`"groups":{"new":` + newAddrs + `,"left":2,"common":3,"changed":1,"same":2},"indicators":[],` +
`"transitions":{"verdicts":["pass","partial","fail"],"matrix":[[0,0,0],[0,0,0],[0,0,0]],"new":[0,0,0],"left":[0,0,0]},"cancelled":{"base":0,"target":0}}`
}
// compareFake is analyticsFake with the comparisons of runs 1 and 2 (run 3 is open).
func compareFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
t.Helper()
fake, ts := analyticsFake(t)
fake.compares = map[string]string{"1-2": compareWith("111"), "2-1": compareWith("222")}
fake.compareLists = map[string]string{
"1-2/changed": `{"group":"changed","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
"1-2/new/verdict_pass": `{"group":"new","indicator":"verdict_pass","columns":["Адрес"],"rows":[["5.6.7.8"]]}`,
}
return fake, ts
}
func compareRequests(fake *fakeControlAPI) []string {
var out []string
for _, r := range fake.analyticsReqs {
if strings.Contains(r, "/compare") {
out = append(out, r)
}
}
return out
}
// By default B is the newest finished run and A the one before it; the open
// run is not offered; the page asks control-api for that pair only.
func TestAnalyticsComparePageDefaultPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare")
for _, want := range []string{
`id="an-base"`, `id="an-target"`, `id="an-swap"`, `id="analytics-data"`, `"new":111`,
`<option value="1" selected>`, `<option value="2" selected>`, // A is run 1, B is run 2
`id="an-dlg"`, `/static/analytics-dialog.js`, `/static/analytics-compare.js`,
"6\u00a0440 адр. · 30% pass",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Count(page, `<option value="1" selected>`) != 1 || strings.Count(page, `<option value="2" selected>`) != 1 {
t.Fatalf("one run is chosen in each list:\n%s", page)
}
if strings.Contains(page, `value="3"`) {
t.Fatalf("an open run must not be offered:\n%s", page)
}
if got := compareRequests(fake); len(got) != 1 || !strings.Contains(got[0], "base=1") || !strings.Contains(got[0], "target=2") {
t.Fatalf("expected one request for base=1&target=2, got %v", got)
}
}
// The pair in the address: both ends, only the new one (the base is the run
// before it), only the old one (the target is the newest other run).
func TestAnalyticsComparePageExplicitPair(t *testing.T) {
fake, ts := compareFake(t)
page := get(t, ts, "/analytics/compare?base=2&target=1")
if !strings.Contains(page, `"new":222`) || !strings.Contains(page, `<option value="2" selected>`) {
t.Fatalf("swapped pair:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=2"); !strings.Contains(page, `"new":111`) {
t.Fatalf("only target=2 must compare with run 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?base=2"); !strings.Contains(page, `"new":222`) {
t.Fatalf("only base=2 must compare with the newest other run, 1:\n%s", page)
}
if page = get(t, ts, "/analytics/compare?target=1"); strings.Contains(page, `id="analytics-data"`) ||
!strings.Contains(page, "нет второго запуска") {
t.Fatalf("run 1 is the oldest, nothing to compare it with:\n%s", page)
}
if got := compareRequests(fake); len(got) != 3 {
t.Fatalf("the refused pair must not reach control-api, got %v", got)
}
}
func TestAnalyticsComparePageWarnings(t *testing.T) {
fake, ts := compareFake(t)
for _, c := range []struct{ query, want string }{
{"base=1&target=1", "Выберите два разных запуска"},
{"base=99&target=1", "Запуск 99 не найден или ещё не завершён"},
// the open run
{"base=1&target=3", "Запуск 3 не найден или ещё не завершён"},
{"base=abc&target=1", "Неверный номер запуска"},
{"base=0&target=1", "Неверный номер запуска"},
} {
page := get(t, ts, "/analytics/compare?"+c.query)
if !strings.Contains(page, c.want) || strings.Contains(page, `id="analytics-data"`) || !strings.Contains(page, `id="an-base"`) {
t.Fatalf("%s: expected the warning %q and the run lists without data, got:\n%s", c.query, c.want, page)
}
}
if got := compareRequests(fake); len(got) != 0 {
t.Fatalf("a bad pair must not reach control-api, got %v", got)
}
// Fewer than two finished runs: nothing to compare.
fake, caURL := newFakeControlAPI(t)
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(1, "finalized", 6440, 1962)}
page := get(t, newTestServer(t, caURL), "/analytics/compare")
if !strings.Contains(page, "минимум два завершённых запуска") || strings.Contains(page, `id="an-base"`) {
t.Fatalf("expected the empty state, got:\n%s", page)
}
}
func TestAnalyticsCompareListProxyAndCSV(t *testing.T) {
_, ts := compareFake(t)
fetch := func(path string) (int, http.Header, string) {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp.StatusCode, resp.Header, string(body)
}
code, _, body := fetch("/analytics/compare/lists/changed?base=1&target=2")
if code != http.StatusOK || !strings.Contains(body, `"1.2.3.4"`) {
t.Fatalf("list: %d %s", code, body)
}
// The indicator and the verdicts travel to control-api.
code, _, body = fetch("/analytics/compare/lists/new?base=1&target=2&indicator=verdict_pass")
if code != http.StatusOK || !strings.Contains(body, `"5.6.7.8"`) {
t.Fatalf("list with an indicator: %d %s", code, body)
}
code, header, body := fetch("/analytics/compare/csv/changed?base=1&target=2")
if code != http.StatusOK || !strings.HasPrefix(header.Get("Content-Type"), "text/csv") ||
!strings.Contains(header.Get("Content-Disposition"), `attachment; filename="compare_changed_run1-2.csv"`) {
t.Fatalf("csv: %d %v %s", code, header, body)
}
for _, c := range []struct {
path string
want int
}{
// no runs
{"/analytics/compare/lists/changed", http.StatusBadRequest},
{"/analytics/compare/lists/changed?base=1", http.StatusBadRequest},
{"/analytics/compare/csv/changed?base=abc&target=2", http.StatusBadRequest},
// control-api says unknown list
{"/analytics/compare/lists/nonsense?base=1&target=2", http.StatusNotFound},
{"/analytics/compare/csv/nonsense?base=1&target=2", http.StatusNotFound},
} {
if code, _, _ := fetch(c.path); code != c.want {
t.Errorf("%s: %d, want %d", c.path, code, c.want)
}
}
}
// The single-run page after the dialog moved to analytics-dialog.js: it still
// carries the dialog, loads the shared script before its own, offers the
// comparison, and both scripts are served.
func TestAnalyticsPageUsesSharedDialog(t *testing.T) {
_, ts := analyticsFake(t)
page := get(t, ts, "/analytics")
iDialog, iPage := strings.Index(page, "/static/analytics-dialog.js"), strings.Index(page, "/static/analytics.js")
if iDialog < 0 || iPage < 0 || iDialog > iPage {
t.Fatalf("analytics-dialog.js must come before analytics.js (%d, %d):\n%s", iDialog, iPage, page)
}
for _, want := range []string{`id="an-dlg"`, `id="an-dlg-tbl"`, `id="an-dlg-csv"`, `id="an-tip"`, `href="/analytics/compare?target=2"`} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
for path, want := range map[string]string{
"/static/analytics-dialog.js": "window.AnalyticsDialog =",
"/static/analytics.js": "window.AnalyticsDialog",
"/static/analytics-compare.js": "window.AnalyticsDialog",
} {
if body := get(t, ts, path); !strings.Contains(body, want) {
t.Errorf("%s does not contain %q", path, want)
}
}
}
// The sidebar: no window-chrome dots next to the logo, the Analytics entry, // The sidebar: no window-chrome dots next to the logo, the Analytics entry,
// and the link state, theme toggle and logout above the navigation. // and the link state, theme toggle and logout above the navigation.
func TestSidebarSessionBlockOnTop(t *testing.T) { func TestSidebarSessionBlockOnTop(t *testing.T) {
@@ -232,3 +414,15 @@ func TestRegistryDrillDownFromAnalytics(t *testing.T) {
t.Fatalf("a malformed subnet must be ignored: %q", last) t.Fatalf("a malformed subnet must be ignored: %q", last)
} }
} }
// The filter of a comparison list goes to control-api as it is.
func TestAnalyticsCompareListPath(t *testing.T) {
got := analyticsCompareListPath(1, 2, "common", compareFilter{Indicator: "verdict_pass", From: "partial", To: "pass"}, true)
want := "/api/v1/admin/analytics/compare/lists/common?base=1&format=csv&from=partial&indicator=verdict_pass&target=2&to=pass"
if got != want {
t.Errorf("path = %q, want %q", got, want)
}
if got := analyticsCompareListPath(3, 4, "new", compareFilter{}, false); got != "/api/v1/admin/analytics/compare/lists/new?base=3&target=4" {
t.Errorf("path = %q", got)
}
}
+3
View File
@@ -32,6 +32,9 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /analytics", s.handleAnalyticsPage) mux.HandleFunc("GET /analytics", s.handleAnalyticsPage)
mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList) mux.HandleFunc("GET /analytics/lists/{kind}", s.handleAnalyticsList)
mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV) mux.HandleFunc("GET /analytics/csv/{kind}", s.handleAnalyticsCSV)
mux.HandleFunc("GET /analytics/compare", s.handleAnalyticsComparePage)
mux.HandleFunc("GET /analytics/compare/lists/{group}", s.handleAnalyticsCompareList)
mux.HandleFunc("GET /analytics/compare/csv/{group}", s.handleAnalyticsCompareCSV)
mux.HandleFunc("GET /validators", s.handleValidatorsPage) mux.HandleFunc("GET /validators", s.handleValidatorsPage)
mux.HandleFunc("POST /validators", s.handleValidatorCreate) mux.HandleFunc("POST /validators", s.handleValidatorCreate)
@@ -0,0 +1,136 @@
/* Comparison of two finished runs: renders the comparison embedded as JSON in
#analytics-data (the groups of addresses, the dynamics of the seven
indicators, the verdict transitions) and opens the address list behind each
number in the shared dialog (analytics-dialog.js). The state of the page is
its address: ?base=A&target=B. */
(function () {
'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, G = R.groups, T = R.transitions;
var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; }
var NAME = {};
R.indicators.forEach(function (d) { NAME[d.key] = d.name; });
/* ---- what each list says about itself ---- */
var GROUP = {
new: ['Новые адреса', 'Есть в запуске B, в A их не было: прибыли в проект и подключились к анализу. Столбцы показывают состояние в B.'],
left: ['Выбывшие адреса', 'Были в запуске A, в B их нет: вышли из состава проекта. Столбцы показывают состояние в A.'],
common: ['Общие адреса', 'Есть в обоих запусках. Столбцы показывают состояние в A → B.'],
changed: ['Изменившиеся адреса', 'Есть в обоих запусках, принадлежность хотя бы к одному из семи индикаторов в A и в B разная.'],
same: ['Адреса без изменений', 'Есть в обоих запусках, принадлежность ко всем индикаторам та же. Другой набор проваленных целей или площадок изменением не считается.'],
entered: ['Вошли в индикатор', 'Общие адреса, которые в A не входили в этот индикатор, а в B входят.'],
exited: ['Вышли из индикатора', 'Общие адреса, которые в A входили в этот индикатор, а в B не входят.']
};
var HINT_ONE = {
2: 'Вердикт адреса, который система выставила при агрегации, а не итог всех проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле запуска. «—» — ingress-проверок нет.',
5: 'Индикаторы страницы «Аналитика», в которые входит адрес. Один адрес может входить в несколько; вердикт — ровно в один из трёх.'
};
var HINT_PAIR = {
2: 'Вердикт адреса в запуске A и в запуске B.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в последнем цикле: в A → в B.',
5: 'Что изменилось между запусками, по шагам: вердикт, вход в индикатор и выход из него, проваленные цели https и площадки ssh, валидатор. У адреса без изменений в индикаторах — «без изменений», даже если набор проваленных целей другой.'
};
function describe(f) {
var g = GROUP[f.group], ind = f.ind ? '«' + (NAME[f.ind] || f.ind) + '»' : '';
var title = g[0] + (f.ind ? ': ' + (NAME[f.ind] || f.ind) : '') + (f.from ? ' · вердикт ' + f.from + ' → ' + f.to : '');
var note = g[1];
if (f.ind && f.group === 'new') note += ' Только входящие в индикатор ' + ind + ' в запуске B.';
if (f.ind && f.group === 'left') note += ' Только входившие в индикатор ' + ind + ' в запуске A.';
if (f.ind && (f.group === 'common' || f.group === 'changed' || f.group === 'same')) note += ' Только входящие в индикатор ' + ind + ' хотя бы в одном из запусков.';
if (f.from) note += ' Только адреса с вердиктом ' + f.from + ' в A и ' + f.to + ' в B.';
return { title: title, note: note, hints: f.group === 'new' || f.group === 'left' ? HINT_ONE : HINT_PAIR };
}
function listURL(root, f) {
return root + encodeURIComponent(f.group) + '?base=' + M.base.id + '&target=' + M.target.id +
(f.ind ? '&indicator=' + encodeURIComponent(f.ind) : '') +
(f.from ? '&from=' + encodeURIComponent(f.from) + '&to=' + encodeURIComponent(f.to) : '');
}
function openList(f) {
var t = describe(f);
A.load(listURL(M.list_url, f), t.title).then(function (l) {
if (!l) return;
A.fill({
title: t.title, note: t.note, hints: t.hints, scope: 'Запуски', runLabel: 'A — ' + M.base.label + '; B — ' + M.target.label,
cols: l.columns, rows: l.rows, csvURL: listURL(M.csv_url, f)
});
});
}
/* ---- the page ---- */
function renderNote() {
function line(name, m, r, cancelled) {
return '<div>' + esc(name + ': запуск ' + m.id + ', ' + m.kind + ', начало ' + m.start + ', завершён ' + m.end + ', длительность ' + m.duration +
', ' + fmt(r.addresses) + ' адр.' + (r.rechecked ? ', перепроверено внутри запуска: ' + fmt(r.rechecked) : '') +
(cancelled ? ', отменено (не участвуют в сравнении): ' + fmt(cancelled) : '') + '.') + '</div>';
}
$('an-cmp-note').innerHTML = line('A', M.base, R.runs.base, R.cancelled.base) + line('B', M.target, R.runs.target, R.cancelled.target);
}
function renderKpis() {
var GO = ' <span class="an-go">список →</span>';
var tiles = [
['Новые', G.new, 'есть в B, не было в A', 'new'],
['Выбывшие', G.left, 'были в A, нет в B', 'left'],
['Общие', G.common, 'есть в обоих запусках', 'common'],
['Изменились', G.changed, pct(G.changed, G.common) + '% общих адресов', 'changed'],
['Без изменений', G.same, pct(G.same, G.common) + '% общих адресов', 'same']
];
$('an-cmp-kpis').innerHTML = tiles.map(function (t) {
var inner = '<div class="an-k">' + t[0] + '</div><div class="an-v">' + fmt(t[1]) + '</div><div class="an-d">' + t[2] + (t[1] ? GO : '') + '</div>';
return t[1]
? '<button type="button" class="an-kpi" data-group="' + t[3] + '" aria-haspopup="dialog">' + inner + '</button>'
: '<div class="an-kpi">' + inner + '</div>';
}).join('');
}
/* a number that opens a list; zero is plain text */
function link(n, attrs, tip) {
return n ? '<button type="button" class="an-lnk" ' + attrs + ' ' + tipAttr(tip) + '>' + fmt(n) + '</button>' : '<span class="an-zero">0</span>';
}
function renderIndicators() {
var COLS = [['new', 'Новые', 'новые адреса, входящие в индикатор'], ['left', 'Выбывшие', 'выбывшие адреса, входившие в индикатор'],
['entered', 'Вошли', 'общие адреса, вошедшие в индикатор'], ['exited', 'Вышли', 'общие адреса, вышедшие из индикатора']];
$('an-cmp-ind').innerHTML = '<thead><tr><th>Индикатор</th><th class="an-r">A</th><th class="an-r">B</th><th class="an-r">Δ</th>' +
COLS.map(function (c) { return '<th class="an-r">' + c[1] + '</th>'; }).join('') + '</tr></thead><tbody>' +
R.indicators.map(function (d) {
var good = d.key === 'verdict_pass' ? d.delta > 0 : d.delta < 0;
var delta = d.delta > 0 ? '+' + fmt(d.delta) : d.delta < 0 ? '−' + fmt(-d.delta) : '0';
return '<tr><td>' + esc(d.name) + '</td><td class="an-num an-r">' + fmt(d.base) + '</td><td class="an-num an-r">' + fmt(d.target) + '</td>' +
'<td class="an-num an-r' + (d.delta ? (good ? ' an-good' : ' an-bad') : '') + '">' + delta + '</td>' +
COLS.map(function (c) {
return '<td class="an-num an-r">' + link(d[c[0]], 'data-group="' + c[0] + '" data-ind="' + d.key + '"', d.name + ': ' + c[2]) + '</td>';
}).join('') + '</tr>';
}).join('') + '</tbody>';
}
function renderTransitions() {
var V = T.verdicts;
$('an-cmp-tr').innerHTML = '<thead><tr><th></th>' + V.map(function (v) { return '<th class="an-r">B: ' + esc(v) + '</th>'; }).join('') + '<th class="an-r">нет в B</th></tr></thead><tbody>' +
V.map(function (from, i) {
return '<tr><td>A: ' + esc(from) + '</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.matrix[i][j], 'data-group="common" data-from="' + from + '" data-to="' + to + '"', 'вердикт ' + from + ' в A → ' + to + ' в B') + '</td>';
}).join('') + '<td class="an-num an-r">' + link(T.left[i], 'data-group="left" data-ind="verdict_' + from + '"', 'выбыли, вердикт в A: ' + from) + '</td></tr>';
}).join('') +
'<tr><td>нет в A</td>' + V.map(function (to, j) {
return '<td class="an-num an-r">' + link(T.new[j], 'data-group="new" data-ind="verdict_' + to + '"', 'новые, вердикт в B: ' + to) + '</td>';
}).join('') + '<td class="an-num an-r">—</td></tr></tbody>';
}
/* ---- wiring ---- */
function onOpen(e) {
var b = e.target.closest('[data-group]');
if (b) openList({ group: b.dataset.group, ind: b.dataset.ind || '', from: b.dataset.from || '', to: b.dataset.to || '' });
}
renderNote(); renderKpis(); renderIndicators(); renderTransitions();
['an-cmp-kpis', 'an-cmp-ind', 'an-cmp-tr'].forEach(function (id) { $(id).addEventListener('click', onOpen); });
})();
@@ -0,0 +1,107 @@
/* The address-list dialog of the analytics pages (markup: the "analytics_dialog"
template) and the tooltip that goes with it. A page loads a list with
AnalyticsDialog.load(url, title), then shows it with AnalyticsDialog.fill(m);
copying and the CSV download work on the list shown last. */
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
var cur = null; // the list shown in the dialog
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 open() {
var d = $('an-dlg');
if (d.open) return;
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
}
/* m: title, note, scope ("Запуск" by default), runLabel, cols, rows, hints
(column index -> text), cellHints (column index -> function of the value),
dist (HTML above the table), csvURL (the file behind "Скачать CSV"). */
function fill(m) {
cur = m;
$('an-dlg-title').textContent = m.title + ' · ' + fmt(m.rows.length);
$('an-dlg-note').textContent = (m.scope || 'Запуск') + ': ' + 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 = '';
}
/* Opens the dialog with the title and "Загрузка…", fetches the list as JSON;
resolves with it, or with null (and a message in the dialog) on a failure. */
function load(url, title) {
$('an-dlg-title').textContent = title;
$('an-dlg-note').textContent = 'Загрузка…';
$('an-dlg-dist').hidden = true;
$('an-dlg-tbl').innerHTML = '';
$('an-dlg-msg').textContent = '';
cur = null;
open();
return fetch(url, { 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 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 = cur.csvURL;
}
$('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; });
window.AnalyticsDialog = { $: $, fmt: fmt, esc: esc, tipAttr: tipAttr, load: load, fill: fill };
})();
+6
View File
@@ -107,6 +107,12 @@ button.an-bar-row:focus-visible { outline: 2px solid var(--accent); outline-offs
.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 { 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-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-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; }
/* comparison of two runs: a number that opens a list, and the delta colours */
.an button.an-lnk { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; color: var(--accent-strong); background: none; border: 0; border-bottom: 1px dashed var(--accent); padding: 0 2px; cursor: pointer; }
.an button.an-lnk:hover { background: var(--surface-alt); }
.an button.an-lnk:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.an-zero { color: var(--text-faint); font: 500 12.5px var(--font-mono); }
.an td.an-good { color: var(--success); font-weight: 700; } .an td.an-bad { color: var(--danger); font-weight: 700; }
#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; } #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; } } @media (prefers-reduced-motion: reduce) { #an-tip { transition: none; } }
+7 -94
View File
@@ -1,21 +1,15 @@
/* Analytics page: renders the report of one finished run (embedded as JSON in /* 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 #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. */ error classes in the shared dialog (analytics-dialog.js). No other run's data
is on the page. */
(function () { (function () {
'use strict'; 'use strict';
var D = JSON.parse(document.getElementById('analytics-data').textContent); var D = JSON.parse(document.getElementById('analytics-data').textContent);
var R = D.report, M = D.meta, S = R.summary, Q = R.quality; var R = D.report, M = D.meta, S = R.summary, Q = R.quality;
function $(id) { return document.getElementById(id); } var A = window.AnalyticsDialog, $ = A.$, fmt = A.fmt, esc = A.esc, tipAttr = A.tipAttr;
function fmt(n) { return Math.round(n).toLocaleString('ru-RU'); }
function pct(a, b) { return b ? Math.round(a / b * 100) : 0; } 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 pct1(a, b) { return b ? (a / b * 100).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) : '0'; }
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function tipAttr(t) { return 'data-tip="' + esc(t) + '"'; }
function vnum(id) { var m = /(\d+)$/.exec(id); return m ? +m[1] : null; } function 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; } function vshort(id) { var n = vnum(id); return n === null ? id : 'v' + n; }
@@ -211,57 +205,11 @@
} }
}; };
var cur = null; // the list shown in the dialog
function listURL(base, kind, cls) { function listURL(base, kind, cls) {
return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : ''); return base + encodeURIComponent(kind) + '?run=' + M.run_id + (cls ? '&class=' + encodeURIComponent(cls) : '');
} }
function csvText(cols, rows) { function loadList(kind, cls, meta) { return A.load(listURL(M.list_url, kind, cls), meta.title); }
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() { function runLabel() {
var o = document.getElementById('an-run'); var o = document.getElementById('an-run');
@@ -272,7 +220,7 @@
var meta = LISTS[kind]; var meta = LISTS[kind];
loadList(kind, '', meta).then(function (l) { loadList(kind, '', meta).then(function (l) {
if (!l) return; 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: '' }); A.fill({ title: meta.title, note: meta.note, hints: meta.hints, cols: l.columns, rows: l.rows, runLabel: runLabel(), csvURL: listURL(M.csv_url, kind, '') });
}); });
} }
@@ -292,30 +240,16 @@
}).join('') + '</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-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>'; '<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({ A.fill({
title: 'Ingress: ' + cls, title: 'Ingress: ' + cls,
note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.', note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, kind: 'error', cls: cls, cols: l.columns, rows: rows, runLabel: runLabel(), dist: dist, csvURL: listURL(M.csv_url, 'error', cls),
hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT }, hints: { 3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT },
cellHints: { 4: function (v) { return VERDICT_VAL[v] || ''; }, 5: statusVal } 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 ---- */ /* ---- wiring ---- */
function renderAll() { function renderAll() {
renderSubnets(); renderSubnets();
@@ -343,25 +277,4 @@
renderTypes(); renderTargets(); renderTypes(); renderTargets();
}); });
$('an-allsub').addEventListener('click', function () { state.all = !state.all; renderSubnets(); 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; });
})(); })();
+3 -17
View File
@@ -19,6 +19,7 @@
</div> </div>
{{if .HasRun}} {{if .HasRun}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script> <script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics.js"></script> <script src="/static/analytics.js"></script>
{{end}} {{end}}
</body> </body>
@@ -46,6 +47,7 @@
{{end}} {{end}}
</select> </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}} {{if .NextURL}}<a class="an-btn" href="{{.NextURL}}" aria-label="Следующий запуск">▶</a>{{else}}<span class="an-btn" aria-disabled="true" style="opacity:.4">▶</span>{{end}}
{{if .HasRun}}<a class="an-btn" href="/analytics/compare?target={{.RunID}}">Сравнить с другим запуском</a>{{end}}
</div> </div>
{{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}} {{if .HasRun}}<p class="an-note" id="an-runnote"></p>{{else}}<p class="an-note">Завершённых запусков пока нет: данные появятся, когда все адреса запуска получат итог.</p>{{end}}
</section> </section>
@@ -114,23 +116,7 @@
<p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p> <p class="an-note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
</section> </section>
<dialog id="an-dlg" aria-labelledby="an-dlg-title"> {{template "analytics_dialog"}}
<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}} {{end}}
</div> </div>
{{end}} {{end}}
@@ -0,0 +1,75 @@
{{define "analytics_compare_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_compare_content" .}}
</main>
</div>
</div>
{{if .HasReport}}
<script type="application/json" id="analytics-data">{{.DataJSON}}</script>
<script src="/static/analytics-dialog.js"></script>
<script src="/static/analytics-compare.js"></script>
{{end}}
</body>
</html>
{{end}}
{{define "analytics_compare_content"}}
<div class="an">
<div class="an-head">
<h1 class="an-title">Сравнение запусков</h1>
<p class="an-sub">Динамика между двумя завершёнными запусками. <a href="/analytics">К аналитике одного запуска</a>.</p>
</div>
{{if .RunsA}}
<section class="an-panel" aria-label="Выбор запусков">
<form class="an-runbar" method="get" action="/analytics/compare">
<label for="an-base">Запуск A (старый)</label>
<select id="an-base" name="base">
{{range .RunsA}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn" type="button" id="an-swap" onclick="var a = document.getElementById('an-base'), b = document.getElementById('an-target'), v = a.value; a.value = b.value; b.value = v; this.form.submit()">Поменять местами</button>
<label for="an-target">Запуск B (новый)</label>
<select id="an-target" name="target">
{{range .RunsB}}<option value="{{.ID}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
<button class="an-btn an-primary" type="submit">Сравнить</button>
</form>
{{if .HasReport}}<div class="an-note" id="an-cmp-note"></div>{{end}}
<p class="an-note">«Новые» — адреса, которые есть в B и которых не было в A; «выбывшие» — были в A, в B их нет. Адрес изменился, если его принадлежность к индикаторам в A и B разная. Если запуски разного состава (например, перепроверка части адресов), новых и выбывших будет много: это состав запусков, а не динамика.</p>
</section>
{{end}}
{{if .HasReport}}
<div class="an-kpis" id="an-cmp-kpis"></div>
<section class="an-panel" aria-labelledby="an-h-ind">
<h2 id="an-h-ind">Динамика по индикаторам</h2>
<div class="an-scroll"><table id="an-cmp-ind"></table></div>
<p class="an-note">Δ = B − A = новые − выбывшие + вошли − вышли. Зелёным выделен рост pass и падение остальных индикаторов, красным обратное. Числа в последних четырёх столбцах открывают список адресов.</p>
</section>
<section class="an-panel" aria-labelledby="an-h-tr">
<h2 id="an-h-tr">Переходы вердикта</h2>
<div class="an-scroll"><table id="an-cmp-tr"></table></div>
<p class="an-note">Вердикт адресов, которые есть в обоих запусках: по строкам вердикт в A, по столбцам в B. Строка «нет в A» — новые адреса по вердикту в B, столбец «нет в B» — выбывшие по вердикту в A.</p>
</section>
{{template "analytics_dialog"}}
{{end}}
</div>
{{end}}
@@ -0,0 +1,21 @@
{{/* The address-list dialog and its tooltip, shared by the analytics pages;
analytics-dialog.js drives it. */}}
{{define "analytics_dialog"}}
<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}}
+2 -2
View File
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access) t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
} }
} }
if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 { if counts["admin"] != 41 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts) t.Fatalf("access counts = %v, want admin=41 agent=5 open=8", counts)
} }
} }
+89 -10
View File
@@ -72,15 +72,21 @@ func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
// analysisFor returns the analysis of a finalized run, from the cache when the // analysisFor returns the analysis of the run named by the {id} of the path;
// run's data has not changed since it was computed. It writes the error // see analysisByID.
// response itself and returns nil when it cannot.
func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis { func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid run id") writeError(w, http.StatusBadRequest, "invalid run id")
return nil return nil
} }
return s.analysisByID(w, r, id)
}
// analysisByID 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) analysisByID(w http.ResponseWriter, r *http.Request, id int64) *analytics.Analysis {
ctx := r.Context() ctx := r.Context()
run, err := s.DB.GetRun(ctx, id) run, err := s.DB.GetRun(ctx, id)
if err != nil { if err != nil {
@@ -152,12 +158,6 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, list) writeJSON(w, http.StatusOK, list)
return 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 name := kind
if kind == analytics.ListError { if kind == analytics.ListError {
if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" { if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" {
@@ -166,12 +166,91 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
name += "-class" name += "-class"
} }
} }
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%s.csv", name, r.PathValue("id")))
}
// writeCSV sends a table as a downloadable CSV file.
func writeCSV(w http.ResponseWriter, columns []string, rows [][]string, filename string) {
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(columns)
_ = cw.WriteAll(rows)
w.Header().Set("Content-Type", "text/csv; charset=utf-8") 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.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }
// compareFor loads the two runs named by ?base=A&target=B (the older and the
// newer one) and compares them. It writes the error response itself and
// returns nil when it cannot: 400 for a missing or malformed id or the same
// run twice, 404 for an unknown run, 409 for one that is still open.
func (s *Server) compareFor(w http.ResponseWriter, r *http.Request) (c *analytics.Comparison, base, target int64) {
ids := [2]int64{}
for i, name := range []string{"base", "target"} {
id, err := strconv.ParseInt(r.URL.Query().Get(name), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid or missing "+name+" run id")
return nil, 0, 0
}
ids[i] = id
}
if ids[0] == ids[1] {
writeError(w, http.StatusBadRequest, "base and target must be different runs")
return nil, 0, 0
}
a := s.analysisByID(w, r, ids[0])
if a == nil {
return nil, 0, 0
}
b := s.analysisByID(w, r, ids[1])
if b == nil {
return nil, 0, 0
}
return analytics.Compare(a, b), ids[0], ids[1]
}
// handleAnalyticsCompare serves the comparison of two finished runs:
// ?base=A (older) &target=B (newer).
func (s *Server) handleAnalyticsCompare(w http.ResponseWriter, r *http.Request) {
if c, _, _ := s.compareFor(w, r); c != nil {
writeJSON(w, http.StatusOK, c)
}
}
// handleAnalyticsCompareList serves the address table of one group of the
// comparison (new|left|common|changed|same|entered|exited), narrowed by
// ?indicator=... and ?from=...&to=... (verdicts), as JSON or, with
// ?format=csv, as a downloadable CSV file.
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
c, base, target := s.compareFor(w, r)
if c == nil {
return
}
q := r.URL.Query()
group := r.PathValue("group")
f := analytics.CompareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}
list, err := c.List(group, f)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if q.Get("format") != "csv" {
writeJSON(w, http.StatusOK, list)
return
}
name := "compare_" + group
if f.Indicator != "" {
name += "_" + f.Indicator
}
if f.From != "" {
name += "_" + f.From + "-" + f.To
}
writeCSV(w, list.Columns, list.Rows, fmt.Sprintf("%s_run%d-%d.csv", name, base, target))
}
func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigGetSubnets(w http.ResponseWriter, r *http.Request) {
list, err := s.DB.ListSubnets(r.Context()) list, err := s.DB.ListSubnets(r.Context())
if err != nil { if err != nil {
+172
View File
@@ -136,6 +136,178 @@ func TestAnalyticsEndpoints(t *testing.T) {
} }
} }
// secondRun builds the run after finishedRun's and returns its id: 9.9.9.1 is
// not in it, 9.9.9.2 is checked again and passes now, 9.9.9.3 is new and has a
// failed ssh. Against the first run: one new, one left, one changed address.
func secondRun(t *testing.T, d *db.DB) int64 {
t.Helper()
ctx := context.Background()
if _, err := d.SubmitIPsAs(ctx, []string{"9.9.9.2", "9.9.9.3"}, db.RunManual); err != nil {
t.Fatal(err)
}
for _, addr := range []string{"9.9.9.2", "9.9.9.3"} {
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 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: "dial tcp: i/o timeout", 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.2"
put(db.InboundSource(1), "ssh", addr, 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) != 2 || rs[0].State != db.RunFinalized {
t.Fatalf("expected two finalized runs: %+v %v", rs, err)
}
return rs[0].ID
}
func TestAnalyticsCompareEndpoints(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "rxmsk"})
a := finishedRun(t, d)
b := secondRun(t, d)
cmp := "/api/v1/admin/analytics/compare"
q := "?base=" + itoa64(a) + "&target=" + itoa64(b)
resp, body := fc.do(http.MethodGet, cmp+q, nil)
var rep struct {
Runs struct {
Base struct{ ID, Addresses int64 } `json:"base"`
Target struct{ ID, Addresses int64 } `json:"target"`
} `json:"runs"`
Groups struct {
New, Left, Common, Changed, Same int
} `json:"groups"`
Indicators []struct {
Key string
Base, Target, Delta, New, Left, Entered, Exited int
} `json:"indicators"`
Transitions struct {
Matrix [][]int `json:"matrix"`
} `json:"transitions"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &rep) != nil || rep.Runs.Base.ID != a || rep.Runs.Target.ID != b ||
rep.Runs.Base.Addresses != 2 || rep.Runs.Target.Addresses != 2 ||
rep.Groups.New != 1 || rep.Groups.Left != 1 || rep.Groups.Common != 1 || rep.Groups.Changed != 1 || rep.Groups.Same != 0 ||
len(rep.Indicators) != 7 || len(rep.Transitions.Matrix) != 3 || rep.Transitions.Matrix[1][0] != 1 {
t.Fatalf("compare: %d %s", resp.StatusCode, body)
}
for _, ind := range rep.Indicators {
if ind.Delta != ind.New-ind.Left+ind.Entered-ind.Exited || ind.Delta != ind.Target-ind.Base {
t.Errorf("indicator %s: %+v", ind.Key, ind)
}
if ind.Key == "ingress_ssh_any" && (ind.Base != 1 || ind.Target != 1 || ind.New != 1 || ind.Exited != 1) {
t.Errorf("ssh any: %+v", ind)
}
}
// Lists: JSON, then CSV with BOM and the name of the file.
var l struct {
Group string `json:"group"`
Columns []string `json:"columns"`
Rows [][]string `json:"rows"`
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || l.Group != "changed" || len(l.Rows) != 1 ||
l.Rows[0][0] != "9.9.9.2" || !strings.HasPrefix(l.Rows[0][2], "partial → pass") {
t.Fatalf("changed list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.3" {
t.Fatalf("new list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/left"+q+"&indicator=verdict_pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("left list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("matrix cell list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/exited"+q+"&indicator=ingress_ssh_any", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" {
t.Fatalf("exited list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/changed"+q+"&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="compare_changed_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+q+"&indicator=verdict_partial&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_new_verdict_partial_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with an indicator: %d %v %q", resp.StatusCode, resp.Header, body)
}
resp, body = fc.do(http.MethodGet, cmp+"/lists/common"+q+"&from=partial&to=pass&format=csv", nil)
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="compare_common_partial-pass_run`+itoa64(a)+"-"+itoa64(b)+`.csv"`) {
t.Fatalf("csv with verdicts: %d %v %q", resp.StatusCode, resp.Header, body)
}
// The order is the caller's: swapped, the new address is the one that left.
swapped := "?base=" + itoa64(b) + "&target=" + itoa64(a)
resp, body = fc.do(http.MethodGet, cmp+"/lists/new"+swapped, nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.1" {
t.Fatalf("swapped: %d %s", resp.StatusCode, body)
}
// An open run cannot be compared.
if _, err := d.SubmitIPs(context.Background(), []string{"9.9.9.9"}); err != nil {
t.Fatal(err)
}
rs, _ := d.ListRuns(context.Background())
open := itoa64(rs[0].ID)
if rs[0].State == db.RunFinalized {
t.Fatalf("expected an open run: %+v", rs[0])
}
ida, idb := itoa64(a), itoa64(b)
for _, c := range []struct {
path string
want int
}{
{cmp, http.StatusBadRequest}, // no ids
{cmp + "?base=" + ida, http.StatusBadRequest},
{cmp + "?target=" + ida, http.StatusBadRequest},
{cmp + "?base=abc&target=" + ida, http.StatusBadRequest},
{cmp + "?base=0&target=" + ida, http.StatusBadRequest},
{cmp + "?base=" + ida + "&target=" + ida, http.StatusBadRequest}, // the same run twice
{cmp + "/lists/changed?base=" + ida + "&target=" + ida, http.StatusBadRequest},
{cmp + "/lists/changed", http.StatusBadRequest},
{cmp + "?base=9999&target=" + ida, http.StatusNotFound},
{cmp + "?base=" + ida + "&target=9999", http.StatusNotFound},
{cmp + "?base=" + ida + "&target=" + open, http.StatusConflict},
{cmp + "/lists/changed?base=" + open + "&target=" + idb, http.StatusConflict},
{cmp + "/lists/nonsense" + q, http.StatusNotFound},
{cmp + "/lists/new" + q + "&indicator=nonsense", http.StatusNotFound},
{cmp + "/lists/entered" + q, http.StatusNotFound}, // an indicator is required
{cmp + "/lists/common" + q + "&from=pass", http.StatusNotFound},
{cmp + "/lists/common" + q + "&from=pass&to=cancelled", http.StatusNotFound},
} {
if resp, body := fc.do(http.MethodGet, c.path, nil); resp.StatusCode != c.want {
t.Errorf("%s: %d %s, want %d", c.path, resp.StatusCode, body, c.want)
}
}
}
// An open run has no analytics yet. // An open run has no analytics yet.
func TestAnalyticsOfOpenRunIsRefused(t *testing.T) { func TestAnalyticsOfOpenRunIsRefused(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t) fc, d, _, _ := newConfigTestHarness(t)
+2
View File
@@ -68,6 +68,8 @@ func (s *Server) routeTable() []route {
{"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, 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}", s.handleAnalyticsRun, accessAdmin},
{"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin}, {"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin},
{"GET /api/v1/admin/analytics/compare", s.handleAnalyticsCompare, accessAdmin},
{"GET /api/v1/admin/analytics/compare/lists/{group}", s.handleAnalyticsCompareList, accessAdmin},
{"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin}, {"GET /api/v1/admin/config/subnets", s.handleConfigGetSubnets, accessAdmin},
{"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, 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},