diff --git a/README.md b/README.md index eba4dee..ff24229 100644 --- a/README.md +++ b/README.md @@ -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 | Повтор после сбоя 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#аналитика-запусков) | diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index e5d4836..00db052 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,4 +1,4 @@ -43160e29c10730592ba1b25c20b42665d043a7d25b94433b2b89d5fd111a5b36 control-api +2f4fa432060fb70c744ab5f593d3f12bbf39f5f6587b4fb6a445c60218d25831 control-api 9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent 3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober -bb3ca7ba12a93c5a018a5c7532dfeb722a866ddfa27a9521cb24d0464fa3595d admin-dashboard +76db6c2dedaa89b7ba8253287a8289c7d72034f5371bcc714a55f3ab257f66ff admin-dashboard diff --git a/bin/admin-dashboard b/bin/admin-dashboard index b0eb867..65d34e1 100755 Binary files a/bin/admin-dashboard and b/bin/admin-dashboard differ diff --git a/bin/control-api b/bin/control-api index 5d67a86..ca3004e 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/docs/API.md b/docs/API.md index 1c1e588..2d8186b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` строка — одна проваленная проверка: адрес, подсеть, площадка, валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»). +### `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_[_][_-]_run-.csv`. + ### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets` Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком: diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 8078449..bbb5ff1 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -58,7 +58,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml | `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). | | `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). | | `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. | -| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки `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`/удаление. | | `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | diff --git a/docs/USAGE.md b/docs/USAGE.md index e8c2235..f7a040a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -446,6 +446,17 @@ curl -s http://:8080/api/v1/admin/registry/203.0.113.10 | python3 - UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр» с фильтром по запуску и подсети (`/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 и, через пробел, подпись. Адрес относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`. diff --git a/docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md b/docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md new file mode 100644 index 0000000..6739357 --- /dev/null +++ b/docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md @@ -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_[_]_run-.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. Не входит в доработку + +Сравнение более чем двух запусков, графики динамики по времени, разбивка новых и выбывших по подсетям, сравнение по другим показателям (классы ошибок, площадки, валидаторы), автоматический выбор «интересных» пар запусков. + +Открытых вопросов нет. Жду команды начать реализацию. diff --git a/docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md b/docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md new file mode 100644 index 0000000..9a48d67 --- /dev/null +++ b/docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md @@ -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 добавляется суффикс `_-`. +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`; миграций нет. diff --git a/internal/analytics/compare.go b/internal/analytics/compare.go new file mode 100644 index 0000000..355a107 --- /dev/null +++ b/internal/analytics/compare.go @@ -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< 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, " ") +} diff --git a/internal/analytics/compare_test.go b/internal/analytics/compare_test.go new file mode 100644 index 0000000..1d346df --- /dev/null +++ b/internal/analytics/compare_test.go @@ -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) + } +} diff --git a/internal/analytics/lists.go b/internal/analytics/lists.go index 73202a5..9d6835e 100644 --- a/internal/analytics/lists.go +++ b/internal/analytics/lists.go @@ -149,17 +149,20 @@ func (an *Analysis) sorted() []*addr { out = append(out, a) } } - sort.Slice(out, func(i, j int) bool { - x, errX := netip.ParseAddr(out[i].res.IPAddress) - y, errY := netip.ParseAddr(out[j].res.IPAddress) - if errX != nil || errY != nil { - return out[i].res.IPAddress < out[j].res.IPAddress - } - return x.Less(y) - }) + sort.Slice(out, func(i, j int) bool { return lessIP(out[i].res.IPAddress, out[j].res.IPAddress) }) return out } +// 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 { + return a < b + } + return x.Less(y) +} + func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) } func (an *Analysis) sortSites(sites []string) []string { diff --git a/internal/dashboard/client.go b/internal/dashboard/client.go index 8aa7605..d201385 100644 --- a/internal/dashboard/client.go +++ b/internal/dashboard/client.go @@ -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 // file name control-api proposed. func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+analyticsListPath(runID, kind, class, true), nil) + 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 { 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 } +// 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). type subnetEntry struct { CIDR string `json:"cidr"` diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index daeeec3..bfc2571 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -44,9 +44,14 @@ type fakeControlAPI struct { // Analytics: the run selector, the report JSON per run, the lists per // "run/kind[/class]" and the subnet list of /settings. - runs []analyticsRun - reports map[int64]string - lists map[string]string + runs []analyticsRun + reports map[int64]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 analyticsReqs []string // 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.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) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/dashboard/handlers_analytics.go b/internal/dashboard/handlers_analytics.go index 0508407..45ccb88 100644 --- a/internal/dashboard/handlers_analytics.go +++ b/internal/dashboard/handlers_analytics.go @@ -182,6 +182,211 @@ func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) { _, _ = 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) { status := http.StatusBadGateway if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 { diff --git a/internal/dashboard/handlers_analytics_test.go b/internal/dashboard/handlers_analytics_test.go index 0c1ad5e..7d35b72 100644 --- a/internal/dashboard/handlers_analytics_test.go +++ b/internal/dashboard/handlers_analytics_test.go @@ -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`, + `▶{{else}}▶{{end}} +{{if .HasRun}}Сравнить с другим запуском{{end}} {{if .HasRun}}

{{else}}

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

{{end}} @@ -114,23 +116,7 @@

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

- -
-

-

- -
-
- -
- - - -
-
-
-
- +{{template "analytics_dialog"}} {{end}} {{end}} diff --git a/internal/dashboard/templates/analytics_compare.html b/internal/dashboard/templates/analytics_compare.html new file mode 100644 index 0000000..2ab0b4d --- /dev/null +++ b/internal/dashboard/templates/analytics_compare.html @@ -0,0 +1,75 @@ +{{define "analytics_compare_page"}} + + +{{template "html_head" .}} + + + +
+ +
+{{template "sidebar_nav" .}} +
+{{template "topbar_mobile" .}} +
+
{{template "banner_inner" .Banner}}
+{{template "analytics_compare_content" .}} +
+
+
+{{if .HasReport}} + + + +{{end}} + + +{{end}} + +{{define "analytics_compare_content"}} +
+
+

Сравнение запусков

+

Динамика между двумя завершёнными запусками. К аналитике одного запуска.

+
+ +{{if .RunsA}} +
+
+ + + + + + +
+{{if .HasReport}}
{{end}} +

«Новые» — адреса, которые есть в B и которых не было в A; «выбывшие» — были в A, в B их нет. Адрес изменился, если его принадлежность к индикаторам в A и B разная. Если запуски разного состава (например, перепроверка части адресов), новых и выбывших будет много: это состав запусков, а не динамика.

+
+{{end}} + +{{if .HasReport}} +
+ +
+

Динамика по индикаторам

+
+

Δ = B − A = новые − выбывшие + вошли − вышли. Зелёным выделен рост pass и падение остальных индикаторов, красным обратное. Числа в последних четырёх столбцах открывают список адресов.

+
+ +
+

Переходы вердикта

+
+

Вердикт адресов, которые есть в обоих запусках: по строкам вердикт в A, по столбцам в B. Строка «нет в A» — новые адреса по вердикту в B, столбец «нет в B» — выбывшие по вердикту в A.

+
+ +{{template "analytics_dialog"}} +{{end}} +
+{{end}} diff --git a/internal/dashboard/templates/analytics_dialog.html b/internal/dashboard/templates/analytics_dialog.html new file mode 100644 index 0000000..e51138b --- /dev/null +++ b/internal/dashboard/templates/analytics_dialog.html @@ -0,0 +1,21 @@ +{{/* The address-list dialog and its tooltip, shared by the analytics pages; + analytics-dialog.js drives it. */}} +{{define "analytics_dialog"}} + +
+

+

+ +
+
+ +
+ + + +
+
+
+
+ +{{end}} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index e4985d2..978c893 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) { t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access) } } - if counts["admin"] != 39 || counts["agent"] != 5 || counts["open"] != 8 { - t.Fatalf("access counts = %v, want admin=39 agent=5 open=8", counts) + if counts["admin"] != 41 || counts["agent"] != 5 || counts["open"] != 8 { + t.Fatalf("access counts = %v, want admin=41 agent=5 open=8", counts) } } diff --git a/internal/httpapi/handlers_analytics.go b/internal/httpapi/handlers_analytics.go index 758e0c1..722a254 100644 --- a/internal/httpapi/handlers_analytics.go +++ b/internal/httpapi/handlers_analytics.go @@ -72,15 +72,21 @@ func (s *Server) handleAnalyticsRuns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } -// analysisFor returns the analysis of a finalized run, from the cache when the -// run's data has not changed since it was computed. It writes the error -// response itself and returns nil when it cannot. +// analysisFor returns the analysis of the run named by the {id} of the path; +// see analysisByID. func (s *Server) analysisFor(w http.ResponseWriter, r *http.Request) *analytics.Analysis { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "invalid run id") return nil } + 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() run, err := s.DB.GetRun(ctx, id) if err != nil { @@ -152,12 +158,6 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, list) return } - var buf bytes.Buffer - buf.WriteString("\xef\xbb\xbf") // UTF-8 BOM, so Excel opens the file as UTF-8 - cw := csv.NewWriter(&buf) - cw.UseCRLF = true - _ = cw.Write(list.Columns) - _ = cw.WriteAll(list.Rows) name := kind if kind == analytics.ListError { if slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(class), "-"), "-"); slug != "" { @@ -166,12 +166,91 @@ func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) { 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-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.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) { list, err := s.DB.ListSubnets(r.Context()) if err != nil { diff --git a/internal/httpapi/handlers_analytics_test.go b/internal/httpapi/handlers_analytics_test.go index 6d543a7..80d905c 100644 --- a/internal/httpapi/handlers_analytics_test.go +++ b/internal/httpapi/handlers_analytics_test.go @@ -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. func TestAnalyticsOfOpenRunIsRefused(t *testing.T) { fc, d, _, _ := newConfigTestHarness(t) diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 899cb36..4071588 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -68,6 +68,8 @@ func (s *Server) routeTable() []route { {"GET /api/v1/admin/analytics/runs", s.handleAnalyticsRuns, accessAdmin}, {"GET /api/v1/admin/analytics/runs/{id}", s.handleAnalyticsRun, accessAdmin}, {"GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", s.handleAnalyticsList, accessAdmin}, + {"GET /api/v1/admin/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}, {"PUT /api/v1/admin/config/subnets", s.handleConfigPutSubnets, accessAdmin}, {"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},