Analytics: make the pass, partial and fail indicators clickable

The three verdict cards on /analytics now open the same dialog as the https/ssh
cards, with the addresses of the run that got this verdict (CSV and copy
included). New list kinds verdict_pass, verdict_partial and verdict_fail in
GET /admin/analytics/runs/{id}/lists/{kind}: address, subnet, validator,
egress and ingress "ok of all", checks stored of expected; partial adds the
reason, the same names as the "Why partial" block. Cancelled addresses are not
listed; the row count equals summary.pass/partial/fail. The fail card stays
inert at zero. addr.incomplete() is shared by the list and the report.

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:00:39 +03:00
1 parent e95b5eb7d5
commit 2f038f8362
15 files changed
+294 -14

No files matched your search

+1
View File
@@ -208,6 +208,7 @@ scripts/run-local-e2e.sh # сквозной прог
| Дата | Веха | Документ | | Дата | Веха | Документ |
|---|---|---| |---|---|---|
| 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#аналитика-запусков) |
| 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) | | 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) |
+2 -2
View File
@@ -1,4 +1,4 @@
0ce9e3b5637b892420a61c8c01843a2348faeaddd11a601ed88e0b1ef25f04c8 control-api 43160e29c10730592ba1b25c20b42665d043a7d25b94433b2b89d5fd111a5b36 control-api
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent 9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober 3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
b5dce1ecee2ebd03603cf8574ccea7129958923ca3db3b29fa4dfa87ac14f5ab admin-dashboard bb3ca7ba12a93c5a018a5c7532dfeb722a866ddfa27a9521cb24d0464fa3595d admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1067,9 +1067,9 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}` ### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}`
Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`. Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`.
`kind`: `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`; `kind`: `verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`;
без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`, без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`,
имя вида `ingress_ssh_all_run1.csv`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка, имя вида `ingress_ssh_all_run1.csv`). Для `verdict_*` — адреса запуска с этим вердиктом (без `cancelled`, по числовому порядку; число строк равно `summary.pass`/`partial`/`fail`): адрес, подсеть, валидатор (по https-проверкам, «—», если их нет), `Egress` и `Ingress` («успешно из всех», «—» без проверок), «Проверок в цикле» (записано из ожидаемых) и у `partial` ещё «Причина» (как в блоке `reasons`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка,
валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»). валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»).
### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets` ### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets`
+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 по площадкам, классы ошибок, валидаторы. Карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). | | `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки `pass`/`partial`/`fail`, карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Подробности — [USAGE.md](USAGE.md#аналитика-запусков). |
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). | | `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
+1 -1
View File
@@ -440,7 +440,7 @@ curl -s http://<control-api>:8080/api/v1/admin/registry/203.0.113.10 | python3 -
(поздние результаты, неполный набор). После изменения «вердикт без опоздавших результатов» поздних результатов в новых (поздние результаты, неполный набор). После изменения «вердикт без опоздавших результатов» поздних результатов в новых
запусках быть не должно. запусках быть не должно.
**Что можно открыть.** Карточки «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все **Что можно открыть.** Карточки `pass`, `partial` и `fail` (список адресов с этим вердиктом: подсеть, валидатор, Egress и Ingress «успешно из всех», число проверок в цикле, у `partial` ещё причина; `fail` с нулём не кликается), карточки «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все
провалены», а также каждая строка блока «Классы ошибок ingress» открывают окно со списком адресов (для класса ошибок провалены», а также каждая строка блока «Классы ошибок ingress» открывают окно со списком адресов (для класса ошибок
— с распределением по валидаторам и статусом каждой проверки). В окне кнопки «Скачать CSV» (файл от control-api, — с распределением по валидаторам и статусом каждой проверки). В окне кнопки «Скачать CSV» (файл от control-api,
UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр» UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр»
@@ -0,0 +1,76 @@
# План: кликабельные индикаторы PASS, PARTIAL, FAIL в разделе «Аналитика»
Статус: реализовано, см. [итог](2026-10-04_09-55_analytics-verdict-indicators-summary.md).
## 1. Что нужно
Вверху страницы `/analytics` есть ряд индикаторов. Сейчас кликабельны только четыре «прикладных»: «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все провалены» (открывают окно со списком адресов, есть «Скачать CSV» и «Копировать»). Нужно сделать кликабельными ещё три: **pass**, **partial**, **fail**. По клику открывается тот же диалог со списком адресов с этим вердиктом.
Остальные индикаторы («Адресов», «Egress OK», «Ingress OK», «Длительность», «Поздние результаты») остаются как есть: в задаче их нет.
## 2. Решение
Новые виды списков строятся по тому же механизму, что и существующие: control-api отдаёт таблицу (JSON или CSV), дашборд проксирует, JS открывает диалог. Новых маршрутов, миграций и новых данных не нужно: вердикт, число проверок и причина `partial` уже вычисляются в `analytics.Compute`.
### 2.1. control-api (`internal/analytics/lists.go`)
Три новых вида списка (`kind`): `verdict_pass`, `verdict_partial`, `verdict_fail`. В список входят адреса запуска с этим вердиктом; отменённые (`cancelled`) не входят, как и в остальных списках и в числах `summary`. Порядок — по числовому адресу (`an.sorted()`).
Столбцы:
| Вид | Столбцы |
|---|---|
| `verdict_pass` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле |
| `verdict_partial` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле, **Причина** |
| `verdict_fail` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле |
- «Валидатор» — тот же, что в списках https (`ShortValidator`); пусто → «—».
- «Egress» и «Ingress» — «успешно из всех» по уровню (`8 из 10`); у уровня без проверок — «—».
- «Проверок в цикле» — сохранённых из ожидаемых (`stored из ExpectedChecks`; если ожидаемое неизвестно, только сохранённое). Позволяет сразу увидеть «неполный набор».
- «Причина» у `partial` — та же формулировка, что в блоке «Почему partial» (`reasonName`), поэтому строки списка совпадают со столбиками блока.
Название CSV: `verdict_pass_run<N>.csv` и т. д. (по общему правилу). Для остальных видов ничего не меняется. Неизвестный `kind` по-прежнему даёт `404`.
Инвариант: число строк списка равно числу на карточке (`summary.pass / partial / fail`).
### 2.2. Дашборд (`internal/dashboard/static/analytics.js`, `analytics.css`)
- В `renderKpis` у плиток `pass`, `partial`, `fail` в поле списка ставится соответствующий `kind`, плитка становится `<button class="an-kpi" data-list=…>` и получает «список →», как у прикладных. У `fail` при значении 0 плитка остаётся некликабельной (список пуст, сейчас там надпись «ни одной полностью проваленной»).
- В `LISTS` добавляются три записи: заголовок («Адреса с вердиктом pass» и т. д.), пояснение («Вердикт адреса за последний цикл запуска, выставленный системой при агрегации…»), подсказки к столбцам (что значит Egress/Ingress, «Проверок в цикле», «Причина»).
- Обработчик клика и диалог менять не нужно: `[data-list]` уже подхватывает любую плитку. Стили есть (`button.an-kpi`); `an-go` для плитки без тега «успех/частично/провал» ставится рядом с существующим тегом.
- Серверной части дашборда (`handlers_analytics.go`, прокси списка и CSV) менять не нужно: `kind` в пути произвольный.
### 2.3. Объём списка pass
В запуске на ~6400 адресов `pass` может быть несколько тысяч строк. Диалог сейчас показывает все строки. Оставляем так (таблица на несколько тысяч строк в браузере открывается нормально) и проверяем на реальном объёме при выкладке; если окно тормозит — отдельным шагом добавим показ первых N строк с пометкой «полный список — в CSV». Выгрузка в CSV и копирование всегда полные.
## 3. Файлы
- `internal/analytics/lists.go` — константы, столбцы и строки новых видов.
- `internal/analytics/analytics.go` — только если нужна вспомогательная функция для строки «Egress/Ingress» (иначе без изменений).
- `internal/httpapi/handlers_analytics.go` — только комментарий с перечнем видов.
- `internal/dashboard/static/analytics.js` — плитки и `LISTS`.
- Документы: `docs/API.md` (перечень `kind`), `docs/USAGE.md` («Что можно открыть»), `docs/DASHBOARD.md` (строка `/analytics`), `README.md` (веха), итог `docs/changes/…-summary.md`.
## 4. Тесты
- `analytics`: для набора с pass, partial, fail и cancelled — число строк каждого списка равно `summary`; отменённые не входят; порядок по адресу; столбцы и значения «Egress/Ingress/Проверок в цикле»; «Причина» совпадает с `reasons`; у неполного набора в «Проверок в цикле» меньше ожидаемого.
- `httpapi`: `GET …/lists/verdict_partial` отдаёт JSON, `?format=csv` — файл с именем `verdict_partial_run<N>.csv`; неизвестный вид — `404` (уже есть, расширить).
- `dashboard`: прокси списка пропускает новые виды; в отданных данных страницы плитки pass/partial/fail имеют ссылки (если страница это проверяет без JS — иначе проверка вручную, п. 5).
- Полный `gofmt -l`, `go build ./... && go vet ./... && go test ./...`.
## 5. Выкладка и проверка
Меняются `control-api` (список) и `admin-dashboard` (статика встроена в бинарник); агенты и prober без изменений, миграций нет. Порядок: проверка пустой очереди, тег отката образов, пересборка, перезапуск. Проверка на стенде: на последнем запуске открыть плитки pass, partial, fail, сверить число в заголовке диалога с числом на плитке, скачать CSV, проверить фильтрацию и подсказки; оценить скорость окна на списке pass.
## 6. Риски
- Большой список pass: см. 2.3.
- Вердикт — оценка системы при агрегации, а не по фактическим проверкам (см. «Качество данных»): у адреса pass могут быть поздние провалы. В пояснении диалога это указывается; столбцы Egress/Ingress показывают фактические проверки.
- Числа на плитках и в списках берутся из одного вычисления отчёта, поэтому расходиться не должны (инвариант в тесте).
## 7. Не входит в доработку
Кликабельность «Egress OK», «Ingress OK», «Адресов», «Поздних результатов»; фильтры и сортировка внутри диалога; ссылка из строки диалога в «Реестр».
Открытых вопросов нет. Жду команды начать реализацию.
@@ -0,0 +1,27 @@
# Итог: кликабельные индикаторы pass, partial, fail в «Аналитике»
План: [2026-10-04_09-55_analytics-verdict-indicators-plan.md](2026-10-04_09-55_analytics-verdict-indicators-plan.md).
Статус: код написан и проверен (gofmt, build, vet, test, синтаксис JS); стенд **не пересобирался**.
## Что изменено
- **control-api** (`internal/analytics/lists.go`): виды списков `verdict_pass`, `verdict_partial`, `verdict_fail`. Строки: адреса запуска с этим вердиктом, без `cancelled`, по числовому порядку. Столбцы: Адрес, Подсеть, Валидатор, Egress, Ingress, «Проверок в цикле»; у `partial` ещё «Причина» (та же формула, что в блоке «Почему partial»). Число строк равно `summary.pass/partial/fail`.
- Метод `addr.incomplete()` (`analytics.go`): «неполный набор» считается одним кодом для списка и для отчёта; логика отчёта не менялась.
- **Дашборд** (`analytics.js`): плитки `pass`, `partial`, `fail` стали кнопками со списком и «список →»; `fail` с нулём остаётся некликабельной. В `LISTS` три записи с пояснением и подсказками к столбцам. Диалог, CSS и прокси дашборда не менялись.
- **Документы**: `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`.
## Особенности
- «Валидатор» берётся по https-проверкам (как в списках https): у адреса без https-проверок он «—».
- «Проверок в цикле» при неизвестном ожидаемом числе (`-1`) — только записанное.
- Вердикт — оценка системы при агрегации; Egress/Ingress в списке показывают фактические проверки. Пояснение есть в диалоге.
## Проверки
- `gofmt -l` пусто (одно выравнивание в тесте поправлено), `go build ./...`, `go vet ./...`, `go test ./...` — все пакеты `ok`; синтаксис `analytics.js` проверен.
- Новые тесты: `TestVerdictLists` (инвариант «строк = summary», отмена, порядок, столбцы, «—», неполный набор, причина совпадает с `reasons`); `TestAnalyticsEndpoints` расширен (JSON, CSV `verdict_partial_run<N>.csv`, `404` на неизвестный вид).
- Не проверено: открытие диалога в браузере и скорость окна на списке pass (нужна выкладка).
## Выкладка
Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard` (статика встроена в бинарник); миграций нет.
+5 -1
View File
@@ -160,6 +160,10 @@ type addr struct {
lateFailed int lateFailed int
} }
// incomplete is true when fewer checks are stored than the cycle expected; an
// unknown expectation (-1) is never incomplete.
func (a *addr) incomplete() bool { return a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks }
// Analysis is a computed report plus the per-address data the lists are cut from. // Analysis is a computed report plus the per-address data the lists are cut from.
type Analysis struct { type Analysis struct {
Report Report Report Report
@@ -334,7 +338,7 @@ func Compute(in Input) (*Analysis, error) {
egFail := a.egress.ok < a.egress.n egFail := a.egress.ok < a.egress.n
inFail := a.ingress.ok < a.ingress.n inFail := a.ingress.ok < a.ingress.n
incomplete := a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks incomplete := a.incomplete()
if incomplete { if incomplete {
rep.Quality.Incomplete++ rep.Quality.Incomplete++
} }
+97
View File
@@ -227,3 +227,100 @@ func TestListsAndShortValidator(t *testing.T) {
} }
} }
} }
func TestVerdictLists(t *testing.T) {
f := &fixture{}
f.addr(1, "10.0.0.10", db.ResultPass, 4) // late failed ssh: pass by verdict, failed by facts
f.addr(2, "10.0.0.2", db.ResultPass, 4)
f.addr(3, "10.0.0.9", db.ResultPartial, 4) // egress failed, one check missing
f.addr(4, "10.0.0.3", db.ResultPartial, -1) // expected number unknown
f.addr(5, "10.0.0.5", db.ResultFail, 1) // no egress checks at all
f.addr(6, "10.0.0.4", db.ResultFail, 2)
f.addr(7, "10.0.0.1", db.ResultCancelled, 4)
for _, reg := range []int64{1, 2} {
f.check(reg, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
}
f.check(1, s1, "ssh", "ip", false, "vkiplab-v1", "dial tcp: i/o timeout", true)
f.check(2, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
f.check(3, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
f.check(3, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
f.check(3, s1, "icmp", "ip", true, "vkiplab-v12", "", false)
f.check(4, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
f.check(4, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp: i/o timeout", false)
f.check(5, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
f.check(6, eg, "https", "https://a.test", false, "vkiplab-v5", "", false)
f.check(6, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
f.check(7, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
an := f.compute(t, nil)
// Every list has as many rows as its number on the card; the cancelled address is in none.
s := an.Report.Summary
rows := map[string][][]string{}
for kind, want := range map[string]int{ListVerdictPass: s.Pass, ListVerdictPartial: s.Partial, ListVerdictFail: s.Fail} {
l, err := an.List(kind, "")
if err != nil {
t.Fatal(err)
}
if l.Kind != kind || len(l.Rows) != want || want != 2 {
t.Fatalf("%s: %d rows, summary %d", kind, len(l.Rows), want)
}
for _, r := range l.Rows {
if r[0] == "10.0.0.1" || len(r) != len(l.Columns) {
t.Errorf("%s: bad row %v for columns %v", kind, r, l.Columns)
}
}
rows[kind] = l.Rows
}
// Numeric address order, not text order.
if p := rows[ListVerdictPass]; p[0][0] != "10.0.0.2" || p[1][0] != "10.0.0.10" {
t.Errorf("pass order: %v", p)
}
l, _ := an.List(ListVerdictPass, "")
if want := []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}; !reflect.DeepEqual(l.Columns, want) {
t.Errorf("pass columns: %v", l.Columns)
}
// Levels count successful of all; the failed late ssh shows in ingress.
if want := []string{"10.0.0.2", "10.0.0.0/24", "v1", "2 из 2", "2 из 2", "4 из 4"}; !reflect.DeepEqual(rows[ListVerdictPass][0], want) {
t.Errorf("pass row: %v", rows[ListVerdictPass][0])
}
if r := rows[ListVerdictPass][1]; r[3] != "2 из 2" || r[4] != "1 из 2" || r[5] != "4 из 4" {
t.Errorf("pass with a late failure: %v", r)
}
// Partial has the reason of the "why partial" block; a short set shows stored of expected,
// an unknown expectation only the stored number.
l, _ = an.List(ListVerdictPartial, "")
if len(l.Columns) != 7 || l.Columns[6] != "Причина" {
t.Errorf("partial columns: %v", l.Columns)
}
if want := []string{"10.0.0.3", "10.0.0.0/24", "v3", "1 из 1", "0 из 1", "2", "Только ingress"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("partial unknown expected: %v", l.Rows[0])
}
if want := []string{"10.0.0.9", "10.0.0.0/24", "v12", "0 из 2", "1 из 1", "3 из 4", "Egress и неполный набор"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("partial incomplete: %v", l.Rows[1])
}
byReason := map[string]int{}
for _, r := range l.Rows {
byReason[r[6]]++
}
for _, x := range an.Report.Reasons {
if byReason[x.Name] != x.Count {
t.Errorf("reason %q: %d rows, %d in the report", x.Name, byReason[x.Name], x.Count)
}
}
// A level without checks shows a dash, so does a missing validator.
l, _ = an.List(ListVerdictFail, "")
if len(l.Columns) != 6 {
t.Errorf("fail columns: %v", l.Columns)
}
if want := []string{"10.0.0.4", "10.0.0.0/24", "v5", "0 из 1", "0 из 1", "2 из 2"}; !reflect.DeepEqual(l.Rows[0], want) {
t.Errorf("fail row: %v", l.Rows[0])
}
if want := []string{"10.0.0.5", "10.0.0.0/24", "—", "—", "0 из 1", "1 из 1"}; !reflect.DeepEqual(l.Rows[1], want) {
t.Errorf("fail row without egress: %v", l.Rows[1])
}
}
+43
View File
@@ -16,6 +16,10 @@ const (
ListIngressSSHAny = "ingress_ssh_any" ListIngressSSHAny = "ingress_ssh_any"
ListIngressSSHAll = "ingress_ssh_all" ListIngressSSHAll = "ingress_ssh_all"
ListError = "error" ListError = "error"
// Addresses by the verdict the system gave them at aggregation.
ListVerdictPass = "verdict_" + db.ResultPass
ListVerdictPartial = "verdict_" + db.ResultPartial
ListVerdictFail = "verdict_" + db.ResultFail
) )
// List is a table of addresses behind one indicator or one error class. // List is a table of addresses behind one indicator or one error class.
@@ -73,6 +77,22 @@ func (an *Analysis) List(kind, class string) (*List, error) {
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")}) l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")})
} }
} }
case ListVerdictPass, ListVerdictPartial, ListVerdictFail:
verdict := strings.TrimPrefix(kind, "verdict_")
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}
if kind == ListVerdictPartial {
l.Columns = append(l.Columns, "Причина")
}
for _, a := range an.sorted() {
if a.res.Verdict != verdict {
continue
}
row := []string{a.res.IPAddress, a.subnet, orDash(ShortValidator(a.https.validator)), okOf(a.egress), okOf(a.ingress), a.checksCell()}
if kind == ListVerdictPartial {
row = append(row, reasonName(a.egress.ok < a.egress.n, a.ingress.ok < a.ingress.n, a.incomplete()))
}
l.Rows = append(l.Rows, row)
}
case ListError: case ListError:
if class == "" { if class == "" {
return nil, ErrUnknownList("error without class") return nil, ErrUnknownList("error without class")
@@ -98,6 +118,29 @@ func (an *Analysis) List(kind, class string) (*List, error) {
return l, nil return l, nil
} }
// checksCell is "stored из expected", or just stored when expected is unknown.
func (a *addr) checksCell() string {
if a.res.ExpectedChecks < 0 {
return fmt.Sprint(a.stored)
}
return fmt.Sprintf("%d из %d", a.stored, a.res.ExpectedChecks)
}
// okOf is "successful из all" of one level, "—" when it has no checks.
func okOf(t typeStat) string {
if t.n == 0 {
return "—"
}
return fmt.Sprintf("%d из %d", t.ok, t.n)
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
// sorted returns the non-cancelled addresses in numeric address order. // sorted returns the non-cancelled addresses in numeric address order.
func (an *Analysis) sorted() []*addr { func (an *Analysis) sorted() []*addr {
out := make([]*addr, 0, len(an.addrs)) out := make([]*addr, 0, len(an.addrs))
+24 -4
View File
@@ -23,13 +23,13 @@
/* ---- indicators ---- */ /* ---- indicators ---- */
function renderKpis() { function renderKpis() {
var late = Q.late_failed_checks_at_pass; var late = Q.late_failed_checks_at_pass, GO = ' <span class="an-go">список →</span>';
var tiles = [ var tiles = [
['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''], ['Адресов', fmt(S.addresses), 'последний цикл каждого адреса', '', ''],
['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>', ''], ['pass', fmt(S.pass), pct(S.pass, S.addresses) + '% адресов', '<span class="an-tag an-t-ok">успех</span>' + GO, 'verdict_pass'],
['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>', ''], ['partial', fmt(S.partial), pct(S.partial, S.addresses) + '% адресов', '<span class="an-tag an-t-warn">частично</span>' + GO, 'verdict_partial'],
['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной', ['fail', fmt(S.fail), S.fail ? pct(S.fail, S.addresses) + '% адресов' : 'ни одной полностью проваленной',
S.fail ? '<span class="an-tag an-t-bad">провал</span>' : '<span class="an-tag an-t-n">нет</span>', ''], S.fail ? '<span class="an-tag an-t-bad">провал</span>' + GO : '<span class="an-tag an-t-n">нет</span>', S.fail ? 'verdict_fail' : ''],
['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''], ['Egress OK', pct1(S.egress_ok, S.addresses) + '%', 'все egress-проверки адреса успешны', '', ''],
['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''], ['Ingress OK', pct1(S.ingress_ok, S.addresses) + '%', 'все ingress-проверки адреса успешны', '', ''],
['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна', ['Egress https: есть провалы', fmt(S.egress_https_any_failed), pct1(S.egress_https_any_failed, S.addresses) + '% адресов, хотя бы одна цель недоступна',
@@ -168,7 +168,27 @@
}; };
function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; } function statusVal(v) { return String(v).indexOf('после') >= 0 ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.'; }
var VERDICT_NOTE = ' Это вердикт адреса, который система выставила при агрегации, а не итог всех проверок: после него могли прийти ещё результаты, поэтому у адреса pass бывают проваленные проверки. Столбцы Egress и Ingress показывают фактические проверки адреса.';
var VERDICT_HINTS = {
2: 'Валидатор, с которого шли egress-проверки адреса. «—» — у адреса нет https-проверок.',
3: 'Сколько egress-проверок адреса успешно из всех записанных в цикле. «—» — egress-проверок нет.',
4: 'Сколько ingress-проверок адреса успешно из всех записанных в цикле (со всех площадок и всех типов). «—» — ingress-проверок нет.',
5: 'Сколько проверок записано в цикле из ожидаемых. Меньше ожидаемого значит, что часть результатов не пришла (неполный набор). Если ожидаемое число неизвестно, показано только записанное.'
};
function verdictList(title, note, reason) {
var hints = {};
Object.keys(VERDICT_HINTS).forEach(function (k) { hints[k] = VERDICT_HINTS[k]; });
if (reason) hints[6] = 'Что не так у адреса: провалены egress-проверки, ingress-проверки или не хватает результатов (неполный набор). Те же названия, что в блоке «Почему partial».';
return { title: title, note: note + VERDICT_NOTE, hints: hints };
}
var LISTS = { var LISTS = {
verdict_pass: verdictList('Адреса с вердиктом pass',
'Вердикт pass: адрес за последний цикл запуска получил успех по всем проверкам, полученным к моменту агрегации.'),
verdict_partial: verdictList('Адреса с вердиктом partial',
'Вердикт partial: часть проверок адреса провалена или результатов не хватило.', true),
verdict_fail: verdictList('Адреса с вердиктом fail',
'Вердикт fail: все проверки адреса провалены.'),
egress_https_any: { egress_https_any: {
title: 'Egress https: адреса с проваленными проверками', title: 'Egress https: адреса с проваленными проверками',
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.', note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.',
+2 -1
View File
@@ -133,7 +133,8 @@ func (s *Server) handleAnalyticsRun(w http.ResponseWriter, r *http.Request) {
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// handleAnalyticsList serves the address table behind one indicator // handleAnalyticsList serves the address table behind one indicator
// (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all) // (kind = egress_https_any|egress_https_all|ingress_ssh_any|ingress_ssh_all),
// the addresses of one verdict (kind = verdict_pass|verdict_partial|verdict_fail)
// or one ingress error class (kind = error, ?class=...), as JSON or, with // or one ingress error class (kind = error, ?class=...), as JSON or, with
// ?format=csv, as a downloadable CSV file. // ?format=csv, as a downloadable CSV file.
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
+13 -2
View File
@@ -106,6 +106,16 @@ func TestAnalyticsEndpoints(t *testing.T) {
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) { !strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="ingress_ssh_any_run`+itoa64(id)+`.csv"`) {
t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body) t.Fatalf("csv: %d %v %q", resp.StatusCode, resp.Header, body)
} }
// A verdict list: 9.9.9.2 is the only partial address.
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial", nil)
if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &l) != nil || len(l.Rows) != 1 || l.Rows[0][0] != "9.9.9.2" || l.Columns[len(l.Columns)-1] != "Причина" {
t.Fatalf("verdict list: %d %s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/verdict_partial?format=csv", nil)
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "9.9.9.2") ||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="verdict_partial_run`+itoa64(id)+`.csv"`) {
t.Fatalf("verdict csv: %d %v %q", resp.StatusCode, resp.Header, body)
}
q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}} q := url.Values{"class": {"SSH: таймаут"}, "format": {"csv"}}
resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil) resp, body = fc.do(http.MethodGet, base+"/"+itoa64(id)+"/lists/error?"+q.Encode(), nil)
if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") { if resp.StatusCode != http.StatusOK || !strings.Contains(resp.Header.Get("Content-Disposition"), "error-ssh_run") {
@@ -113,8 +123,9 @@ func TestAnalyticsEndpoints(t *testing.T) {
} }
for path, want := range map[string]int{ for path, want := range map[string]int{
base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing base + "/" + itoa64(id) + "/lists/error": http.StatusNotFound, // class missing
base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound, base + "/" + itoa64(id) + "/lists/nonsense": http.StatusNotFound,
base + "/" + itoa64(id) + "/lists/verdict_cancelled": http.StatusNotFound,
base + "/9999": http.StatusNotFound, base + "/9999": http.StatusNotFound,
base + "/abc": http.StatusBadRequest, base + "/abc": http.StatusBadRequest,
base + "/9999/lists/ingress_ssh_any": http.StatusNotFound, base + "/9999/lists/ingress_ssh_any": http.StatusNotFound,