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:
1 parent
e95b5eb7d5
commit
2f038f8362
15 files changed
+294
-14
No files matched your search
@@ -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
@@ -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.
Binary file not shown.
+2
-2
@@ -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
@@ -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
@@ -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` (статика встроена в бинарник); миграций нет.
|
||||||
@@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 адреса провалена в последнем цикле запуска.',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user