Add plans for the analytics section and for verdicts without late results
- docs/changes: plan of the "Аналитика" section (runs as selectable timestamps, API, page layout, acceptance numbers from the 6440-address run) and plan of the verdict-integrity change (causes found in the code and in the data, decisions, tests, rollout). - docs/mockups/analytics-mockup.html: self-contained HTML mockup of the analytics page for review in a browser. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1366ecbdea
commit
db73409e8f
3 files changed
+722
No files matched your search
@@ -0,0 +1,141 @@
|
||||
# План: раздел «Аналитика»
|
||||
|
||||
Статус: план, код не менялся. Основа — прогон 6440 адресов (2026-10-02 13:47 – 22:29 UTC) и три отчёта в `analysis/`.
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Сейчас выводы по прогону получены ручными запросами к БД: срезы по подсетям, провалы по целям, ingress по площадкам, расхождение вердикта и проверок. Нужна страница, которая показывает то же самое постоянно, для любого прогона, без SQL.
|
||||
|
||||
Главное требование: переключать **отметку времени** — завершённый запуск проверки, — чтобы данные разных запусков не смешивались.
|
||||
|
||||
## 2. Ключевая находка: «запуска» в данных нет
|
||||
|
||||
`cycle_id` считается **на адрес** (`ip_registry.next_cycle`), а не на весь прогон. В БД сейчас: цикл 1 у 6431 адреса, циклы 2–4 у 9 перепроверенных. По `cycle_id` нельзя выбрать «прогон от 2 октября»: он ничего не группирует.
|
||||
|
||||
Группировка по времени тоже ненадёжна: прогон 6440 адресов шёл 8 ч 42 мин, а перепроверки внутри него шли в то же время.
|
||||
|
||||
Поэтому нужна новая сущность — **запуск** (`run`). Это единственное изменение схемы, без него переключатель невозможен.
|
||||
|
||||
## 3. Модель данных (миграция `0010`)
|
||||
|
||||
Новые таблицы:
|
||||
|
||||
- `check_runs(id, kind, started_at, finalized_at, state)`. `kind`: `auto` (автоцикл), `manual` (добавление, скан, перепроверка). `state`: `open` или `finalized`.
|
||||
- `run_results(run_id, registry_id, ip_address, cycle_id, verdict, aggregated_at, expected_checks, recorded_at_aggregation)`. Одна строка на адрес в запуске. Нужна, потому что вердикт сейчас хранится только в `ip_queue.overall_result` и затирается при перепроверке.
|
||||
- `subnets(cidr PRIMARY KEY, label)`. Список подсетей заказчика (45 шт.) не лежит ни в БД, ни в репозитории, его задаёт администратор.
|
||||
|
||||
Новые колонки: `run_id` в `ip_queue` и `checks`. Индекс `idx_checks_run(run_id, registry_id)`.
|
||||
|
||||
### Правила запуска
|
||||
|
||||
1. Новый запуск открывается, когда в очередь попадает адрес, а открытого запуска нет. Автоцикл открывает запуск явно в начале фазы.
|
||||
2. Пока запуск открыт, все добавленные и перепроверяемые адреса входят в него.
|
||||
3. Запуск **завершается**, когда все его строки очереди в конечном состоянии (`done`, `failed`, `occupied`, отмена). Тогда `finalized_at` = последняя агрегация.
|
||||
4. Перепроверка после завершения открывает **новый** запуск. Поэтому старый запуск не меняется.
|
||||
5. Если адрес перепроверен внутри открытого запуска, в аналитике берётся его последний цикл этого запуска.
|
||||
6. Поздние результаты (пришли после агрегации, как у 50 адресов в анализе 6.5) остаются в своём запуске: `run_id` берётся из строки очереди при записи проверки. Их число показывается отдельно.
|
||||
|
||||
### Заполнение уже накопленных данных
|
||||
|
||||
Запуски выделяются по паузе: если между окончаниями соседних циклов больше 60 минут, начинается новый запуск. Максимальная пауза в текущей БД 0,8 минуты, поэтому получится **один запуск на 6440 адресов**, 9 перепроверок войдут в него. Вердикт берётся из `ip_queue.overall_result`, для прежних циклов без него считается по проверкам и помечается как расчётный.
|
||||
|
||||
## 4. API (control-api, только чтение)
|
||||
|
||||
- `GET /api/v1/admin/analytics/runs` — список запусков: id, тип, начало, конец, адресов, доли `pass/partial/fail`, состояние. Открытый запуск виден, но помечен как идущий.
|
||||
- `GET /api/v1/admin/analytics/runs/{id}` — все блоки страницы одним ответом (десятки КБ):
|
||||
- **summary**: адресов; вердикты; Egress OK и Ingress OK по фактическим проверкам; доли по типам проверок; индикаторы прикладного уровня: адреса, провалившие все egress-проверки https (на запуске 02.10: 307, из них по всем 5 целям 290), и адреса, провалившие все ingress-проверки ssh (7; хотя бы с одной площадки 222); длительность и скорость; число поздних результатов; число неполных наборов.
|
||||
- **partial_reasons**: причины `partial` (только egress; ingress и egress; только ingress; неполный набор; только неполный набор).
|
||||
- **subnets**: по каждой подсети — адресов, `pass`, Egress OK, Ingress OK.
|
||||
- **targets**: egress по цели и типу проверки (процент провалов); матрица «подсеть × цель» для выбранного типа.
|
||||
- **sites**: ingress по площадке и типу проверки.
|
||||
- **validators**: доля провалов egress по валидаторам.
|
||||
- **errors**: классы ошибок по уровню и типу (таймаут, нет маршрута, баннер SSH и др.), классификатор по `checks.detail`.
|
||||
- **data_quality**: поздние результаты, неполные наборы, адреса, где вердикт расходится с проверками.
|
||||
- `GET/PUT /api/v1/admin/config/subnets` — список подсетей (замена целиком, текстом по одной в строке).
|
||||
|
||||
Группировка по типу проверки везде одна и та же, правило `CheckFamily` из `internal/db/models.go` (tcp-22 и tcp-443 → `tcp`). Новый тип появляется в таблицах сам.
|
||||
|
||||
Адрес → подсеть определяется в Go по самому длинному совпавшему префиксу (SQLite не умеет работать с CIDR). Для 6440 адресов это миллисекунды.
|
||||
|
||||
Расчёт идёт одним проходом по `checks` запуска (~190 тыс. строк). Результат завершённого запуска кэшируется в памяти по `run_id` и `MAX(checks.id)` запуска, чтобы поздний результат сбросил кэш.
|
||||
|
||||
## 5. Макет страницы `/analytics?run=<id>`
|
||||
|
||||
```
|
||||
┌─ Аналитика ─────────────────────────────────────────────────────────────┐
|
||||
│ Запуск: ◀ [ 02.10 13:47 → 22:29 · авто · 6440 адр · 30% pass ▾ ] ▶ │
|
||||
│ (идущий запуск виден в списке, но недоступен: «идёт, 120 из 800») │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [Адресов 6440] [pass 1962 · 30%] [partial 4478 · 70%] [fail 0] │
|
||||
│ [Egress OK 31%] [Ingress OK 97%] [Длительность 8 ч 42 м] [Поздних 246] │
|
||||
├──────────────────────────────────┬───────────────────────────────────────┤
|
||||
│ Почему partial │ Данные: качество │
|
||||
│ ▇▇▇▇▇▇▇▇▇▇▇▇ только egress 4146 │ Поздние результаты 246 (50 адр.)│
|
||||
│ ▇ ingress+egress 157 │ Неполный набор 167 адресов │
|
||||
│ ▇ egress+неполный набор 125 │ Вердикт ≠ проверки 50 адресов │
|
||||
│ ▏ только ingress 8 │ │
|
||||
├──────────────────────────────────┴───────────────────────────────────────┤
|
||||
│ Подсети [сорт: хуже всего ▾] [показать все 43] │
|
||||
│ подсеть адр. pass% Egress OK% Ingress OK% │
|
||||
│ 161.104.108.0/23 45 ░░░░░ 0% ░░░░░ 0% ▇▇▇▇▇ 100% │
|
||||
│ 83.166.248.0/21 659 ▇░░░░ 11% ▇░░░░ 12% ▇▇▇▇░ 91% →реестр│
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Egress по целям тип: [https] [icmp] [все] │
|
||||
│ цель провал | подсеть × цель (тепловая карта) │
|
||||
│ packages.ubuntu.com 60% | 83.166.248.0/21 ■■□■■ │
|
||||
│ dl-cdn.alpinelinux.org 34% | 212.233.72.0/21 ■■□■■ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Ingress по площадкам площадка × тип (tcp/ssh/icmp) | классы ошибок │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Валидаторы: полоса «доля провалов egress» по 20 валидаторам (≈33–38%) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Два индикатора прикладного уровня (Egress https и Ingress ssh: все провалены) кликабельны: клик открывает окно со списком адресов и кнопкой «Скачать CSV». В рабочей странице список берётся из `GET /admin/analytics/runs/{id}/addresses?list=egress_https_all_failed|ingress_ssh_all_failed` (CSV собирается на сервере, `Content-Disposition: attachment`, UTF-8 с BOM), чтобы не зависеть от размера страницы.
|
||||
- Каждая строка блока «Классы ошибок ingress» кликабельна: окно показывает распределение проваленных проверок класса по валидаторам, вердикт адреса, статус проверки («в вердикте» или «после вердикта») и список с кнопкой «Скачать CSV». Эндпоинт: `GET /admin/analytics/runs/{id}/errors/{class}` (JSON и `?format=csv`).
|
||||
- Выбор запуска хранится в адресной строке (`?run=`), ссылку можно переслать. По умолчанию — последний завершённый запуск.
|
||||
- Каждый блок рассчитан только на выбранный запуск. Данных других запусков на странице нет.
|
||||
- Строка подсети, ячейка матрицы и причина `partial` — ссылки в «Реестр» с фильтром, чтобы увидеть конкретные адреса. Для этого в `GET /admin/registry` и `/registry` добавляются параметры `run`, `subnet`, `target`, `reason`; страница «Реестр» тоже учитывает выбранный запуск.
|
||||
- Рисование: HTML и встроенный SVG на сервере, без библиотек графиков (в дашборде их нет). Цвета и тепловая карта — по руководству навыка `dataviz`, с тёмной темой, как в остальном дашборде.
|
||||
- Пункт «Аналитика» в боковом меню (`internal/dashboard/templates/layout.html`).
|
||||
|
||||
## 6. Файлы
|
||||
|
||||
- БД: `internal/db/migrations/0010_check_runs.sql`, `models.go`, `queries_runs.go`, правки `queries_ipqueue.go` (`SubmitIPs`, `FinishIP`, `CancelIP`, `ClearAllIPs`) и `queries_checks.go` (`UpsertCheck` ставит `run_id`).
|
||||
- Оркестратор: `internal/orchestrator/orchestrator.go` — запись `run_results` при агрегации, завершение запуска; автоцикл открывает запуск.
|
||||
- Аналитика: новый пакет `internal/analytics` (расчёт блоков, классификатор ошибок, подсети).
|
||||
- API: `internal/httpapi/handlers_analytics.go`, `routes.go`, `docs/API.md`.
|
||||
- Дашборд: `internal/dashboard/handlers_analytics.go`, `templates/analytics.html`, `static/dashboard.css`, пункт меню, форма списка подсетей на `/settings`.
|
||||
|
||||
## 7. Поставка тремя шагами
|
||||
|
||||
1. **Запуски**: миграция, правила, заполнение накопленных данных. Результат: в БД один запуск на 6440 адресов. Видимых изменений в UI нет.
|
||||
2. **API аналитики**: расчёт и эндпоинты.
|
||||
3. **Страница**: макет выше, фильтры в реестре.
|
||||
|
||||
Каждый шаг отдельно собирается, тестируется и выкатывается на стенд.
|
||||
|
||||
## 8. Проверка
|
||||
|
||||
- Контрольные числа: расчёт по запуску на копии боевой БД (`rxprod-compose/capi-db`) должен дать цифры отчётов `analysis/`: 6440 адресов, 1962 `pass` / 4478 `partial`; провал egress у 4437; провал HTTPS по `packages.ubuntu.com` у 3841; 290 адресов провалили все цели; ingress провален у 224; 50 `pass` с поздними провалами; 167 неполных наборов. Если цифры не сходятся, расчёт неверен.
|
||||
- Миграция на копии боевой БД: ровно один запуск, `run_id` заполнен у всех `checks` и `ip_queue`, повторный запуск миграции безопасен.
|
||||
- Тесты: изоляция запусков (два запуска с разными результатами у одного адреса не пересекаются); перепроверка после завершения открывает новый запуск, внутри открытого — нет; поздний результат попадает в свой запуск; идущий запуск не выбирается; новый тип проверки (`dns`) появляется в таблицах; подсети (самый длинный префикс, адрес вне списка → «прочие»).
|
||||
- Нагрузка: страница на запуске 6440 адресов, расчёт до ~1 с, повторный запрос из кэша быстрее; `EXPLAIN QUERY PLAN` по `idx_checks_run`.
|
||||
- Вручную на стенде: выбрать запуск, сверить ключевые числа с отчётами, пройти по ссылке из подсети в реестр.
|
||||
|
||||
## 9. Допущения и ограничения
|
||||
|
||||
- **Валидатор у ingress-проверок.** В `checks.validator_id` для проверок пробера пусто. Валидатор адреса в цикле берётся из события `fip_associated` (поле `validator_id` в payload) по `registry_id` и `cycle_id`. На запуске 02.10 все 950 проваленных ingress-проверок нашли валидатора. Надёжнее писать `validator_id` в ingress-проверку при приёме результата; это отдельное маленькое изменение шага 1.
|
||||
- **Классификатор ошибок** работает по `checks.detail` и типу проверки; на запуске 02.10 он даёт 7 классов с суммой 950 (SSH: таймаут 327, ICMP: нет ответа 276, TCP-22: таймаут 260, SSH: баннер «Not allowed» 47, SSH: нет маршрута 18, ICMP: time exceeded 15, TCP-22: нет маршрута 7). Новые тексты ошибок попадают в класс «прочее» и видны в списке.
|
||||
- **Новое наблюдение для блока «Качество данных».** 844 из 950 проваленных ingress-проверок (89%) записаны после агрегации вердикта (среди всех ingress-проверок после вердикта пришло 1430 из 76644, около 1,9%). То есть таймауты приходят позже вердикта систематически, а не случайно. В отчёте анализа 6.5 это видно только для 246 провалов у адресов `pass`.
|
||||
|
||||
- Список подсетей даёт администратор (форма на `/settings`). Пока он пуст, подсети группируются автоматически по `/24`.
|
||||
- Порог 60 минут для заполнения накопленных данных — только для старых записей, настройкой не делается.
|
||||
- Глубина истории (`history_retention_cycles` > 0) удаляет старые проверки, и аналитика старых запусков теряет детали. По умолчанию 0 (хранить всё). Вердикты в `run_results` остаются.
|
||||
- Перед выкладкой миграции обязательна копия `rxprod-compose/capi-db/control-api.db`.
|
||||
- Вне объёма (следующий этап): сравнение двух запусков, выгрузка в CSV, оповещения.
|
||||
- Вердикт системы и фактические проверки считаются отдельно и показаны рядом (по анализу 6.5 они расходятся у 50 адресов), чтобы поздние результаты не скрывались.
|
||||
|
||||
## 10. Порядок по правилам проекта
|
||||
|
||||
План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа.
|
||||
@@ -0,0 +1,83 @@
|
||||
# План: вердикт без опоздавших результатов
|
||||
|
||||
Статус: план, код не менялся. Основа: код `internal/orchestrator`, `internal/probercore`, `internal/httpapi` и БД стенда (запуск 2026-10-02, 6440 адресов).
|
||||
|
||||
## 1. Требование
|
||||
|
||||
Вердикт адреса и его проверки должны совпадать. После вердикта результат не может появиться или измениться. Вердикт можно пересчитать из сохранённых проверок и получить то же значение.
|
||||
|
||||
## 2. Причины (проверены по коду и данным)
|
||||
|
||||
1. **Prober проверяет один адрес много раз.** `GET /probers/{site}/assignments` отдаёт все адреса в состоянии `checking`. Prober (`probercore.pollOnce`) обходит их при каждом опросе (каждые 5 с) и ничего не помнит о том, что адрес уже проверен. Пока адрес ждёт остальные площадки и egress, он проверяется снова и снова.
|
||||
2. **Новый результат затирает старый.** Результаты пишутся через `UpsertCheck` с `ON CONFLICT DO UPDATE`. Строка живёт с первой записи, а значения берутся из последнего круга. В БД 61 406 из 76 644 ingress-строк (80%) перезаписаны позже первой записи более чем на 3 с. У egress перезаписей нет (агент проверяет один раз).
|
||||
3. **Записи принимаются после вердикта.** `Orchestrator.RecordCheck` вызывает `UpsertCheck` без проверки состояния адреса. Итог на запуске 02.10:
|
||||
- ingress: 1430 строк записаны после вердикта (844 проваленных и 586 успешных); из 844 проваленных 833 записаны уже после отвязки Floating IP, то есть проверяли адрес, который к валидатору уже не привязан;
|
||||
- egress: 351 строка после вердикта (32 проваленных).
|
||||
4. **Окно считается не с начала проверки.** `isReadyToAggregate` сравнивает `assigned_at` с порогом `checking_window_seconds` (120 с). `assigned_at` ставится при выдаче адреса валидатору, то есть окно включает привязку Floating IP, паузу `fip_settle_seconds` (30 с) и self-check (до 60 с). На сами проверки остаётся около 30 с. По окну агрегировано 509 адресов (8%), у них часть результатов не успела прийти.
|
||||
5. **Время сравнивается чужими часами.** `checks.checked_at` ставит prober или агент на своей машине, а `aggregated_at` — control-api. При расхождении часов «после вердикта» определяется неверно.
|
||||
|
||||
Следствие: часть проваленных ingress-проверок в БД — не провалы адреса, а результат проверки уже отвязанного Floating IP. Отчёты по провалам ingress (в том числе таймаутам) за запуск 02.10 нужно читать с этой поправкой.
|
||||
|
||||
## 3. Решение
|
||||
|
||||
### A. Одна проверка адреса на площадку и попытку
|
||||
`handleProberAssignments` не отдаёт адрес, для которого эта площадка уже отметила `complete` в `ip_site_checks` в текущей попытке. Новая попытка (повтор после сбоя) отдаёт адрес снова. Prober менять не нужно, пересобирается только `control-api`. Побочный эффект: нагрузка на prober и на сеть падает в разы.
|
||||
|
||||
### B. После вердикта запись закрыта
|
||||
`UpsertCheck` пишет строку только если адрес в состоянии `checking` и номер попытки совпадает. Проверка выполняется в одном SQL-запросе (условие в `INSERT … SELECT … WHERE EXISTS`), без гонок.
|
||||
- Отброшенный результат не пишется и не меняет существующую строку.
|
||||
- Ответ агенту и prober остаётся `200 {"ok": true, "ignored": true}`, чтобы они не повторяли запрос.
|
||||
- На каждую отброшенную пачку пишется одно событие `result_dropped` (источник, число проверок). Счётчик виден в аналитике; в норме он равен нулю или близок к нему.
|
||||
|
||||
### C. Окно проверки считается с начала проверки
|
||||
Новая колонка `ip_queue.checking_started_at`, ставится в `SetChecking`. `isReadyToAggregate` сравнивает порог с ней, а не с `assigned_at`. Значение `checking_window_seconds` не меняется (120 с), но теперь это 120 с именно на проверки. Для строк без значения (старые данные) используется `assigned_at`.
|
||||
|
||||
### D. Время записи ставит сервер
|
||||
Новая колонка `checks.recorded_at` — время последней записи по часам control-api; при перезаписи обновляется. Все сравнения «до/после вердикта» идут по ней, а не по `checked_at`. Для старых строк значение равно `created_at`.
|
||||
|
||||
### E. Вердикт воспроизводим
|
||||
При агрегации в событие `aggregated` дописываются `egress_total`, `ingress_total`. Тест-инвариант: вердикт, пересчитанный по сохранённым проверкам адреса с учётом недостающих, равен `overall_result`.
|
||||
|
||||
### F. Недостающие результаты
|
||||
Правило не меняется: недостающая проверка считается провалом, вердикт не выше `partial`. Благодаря C число таких адресов должно упасть. Остаток показывается отдельно («нет результата»), а не смешивается с провалами.
|
||||
|
||||
### G. Старые данные
|
||||
В миграции `0010` колонка `checks.after_verdict` (0/1) для существующих строк: `1`, если `checked_at` позже `aggregated_at` строки очереди. Пересчитать затёртые результаты нельзя, поэтому в аналитике они помечаются как недостоверные и показываются отдельно (на запуске 02.10: 1430 ingress и 351 egress). После внедрения для новых данных значение всегда `0`.
|
||||
|
||||
Отвергнутый вариант: пересчитывать вердикт при опоздавших результатах. К этому моменту Floating IP уже отвязан, и такие результаты ничего не доказывают.
|
||||
|
||||
## 4. Файлы
|
||||
|
||||
- `internal/db/migrations/0010_verdict_integrity.sql` — колонки `ip_queue.checking_started_at`, `checks.recorded_at`, `checks.after_verdict`.
|
||||
- `internal/db/queries_checks.go` — условная запись, признак «отброшено».
|
||||
- `internal/db/queries_ipqueue.go` — `SetChecking`, выборка для prober без адресов с готовой площадкой.
|
||||
- `internal/orchestrator/orchestrator.go` — `RecordCheck` возвращает признак, `isReadyToAggregate` по `checking_started_at`, поля в событии `aggregated`.
|
||||
- `internal/httpapi/handlers_agent.go`, `handlers_prober.go` — ответ `ignored`, событие `result_dropped`.
|
||||
- Документы: `docs/API.md` (ответы на запись результатов), `docs/USAGE.md`, `README.md`.
|
||||
- Нумерация миграций: этот план идёт раньше плана аналитики (`…_analytics-section-plan.md`); аналитика получает `0011`.
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- Запись после вердикта игнорируется: строка не создана и не изменена; запись в состоянии `checking` проходит; запись старой попытки игнорируется.
|
||||
- Повторная запись той же проверки внутри окна идемпотентна.
|
||||
- `assignments`: после `complete` площадки адрес не возвращается ей, но возвращается остальным; после повтора попытки возвращается снова.
|
||||
- Окно считается с `checking_started_at`: адрес, назначенный давно, но начавший проверку только что, не агрегируется по окну.
|
||||
- Инвариант вердикта: для набора сценариев (все успешны, часть провалена, часть недостающих, ничего) вердикт равен пересчёту.
|
||||
- Миграция на копии боевой БД: `after_verdict` = 1 для 1430 ingress и 351 egress строк запуска 02.10, остальное без изменений; повторный запуск безопасен.
|
||||
|
||||
## 6. Проверка на стенде
|
||||
|
||||
1. Копия `rxprod-compose/capi-db/control-api.db`, пересборка и перезапуск `control-api` (по процедуре из `docs/SETUP.md`). Prober и агенты не пересобираются.
|
||||
2. Контрольный прогон на небольшой партии (например, 300 адресов).
|
||||
3. Ожидаемый результат: ни одной строки `checks` с `recorded_at` позже `aggregated_at` (`after_verdict = 0`); нет перезаписанных ingress-строк (`recorded_at` равен первой записи); событий `result_dropped` нет или единицы; адресов с агрегацией по окну заметно меньше 8%.
|
||||
4. Сравнение с запуском 02.10: доля проваленных ingress-проверок (таймауты ssh, tcp-22, icmp) должна уменьшиться, если часть прежних провалов была отвязанным Floating IP. Если не уменьшится, версия про отвязку неверна, и нужно искать другую причину.
|
||||
|
||||
## 7. Риски
|
||||
|
||||
- Отброшенные результаты теряются. Это сознательно: после вердикта Floating IP отвязан. Счётчик `result_dropped` показывает, насколько часто это случается.
|
||||
- Уменьшение числа проверок prober меняет нагрузку на сеть площадок. Это ожидаемый эффект, а не риск, но стоит предупредить владельцев площадок.
|
||||
- Новое окно (с начала проверки) может чуть увеличить длительность прогона на адресах, у которых действительно не пришли результаты. Значение окна настраивается.
|
||||
|
||||
## 8. Порядок по правилам проекта
|
||||
|
||||
План (этот файл) → реализация → `…-summary.md` → обновление `README.md` и `docs/` → пересборка стенда → контрольный прогон → обновление графа. Раздел «Аналитика» (план `2026-10-03_16-39`) строится после этого изменения: «Поздние результаты» там становятся контрольным показателем, который должен быть равен нулю.
|
||||
@@ -0,0 +1,498 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Аналитика запусков</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap">
|
||||
<style>
|
||||
/* Layout: sidebar shell like the real dashboard; one scrolling page, summary first, detail below.
|
||||
Tokens are the dashboard's own (internal/dashboard/static/dashboard.css); heat ramp = one blue hue, validated sequential steps. */
|
||||
:root {
|
||||
--bg: #EEF1F5; --surface: #FFFFFF; --surface-alt: #E4E9F0;
|
||||
--border: #CBD3DE; --border-soft: #DCE2EA;
|
||||
--text: #12161F; --text-muted: #566073; --text-faint: #8992A3;
|
||||
--accent: #2F5FE0; --accent-strong: #1F45B0; --accent-soft: #E1E9FC;
|
||||
--success: #157F45; --success-soft: #DFF3E7;
|
||||
--warning: #9A6400; --warning-soft: #F8EDD4;
|
||||
--danger: #B4291D; --danger-soft: #FADEDA;
|
||||
--neutral: #5B6576; --neutral-soft: #E4E8EE;
|
||||
--bar: #3987e5; --bar-track: #E4E9F0;
|
||||
--h1:#cde2fb; --h2:#9ec5f4; --h3:#6da7ec; --h4:#3987e5; --h5:#256abf; --h6:#184f95; --h7:#0d366b;
|
||||
--hi1:#12161F; --hi2:#12161F; --hi3:#12161F; --hi4:#FFFFFF; --hi5:#FFFFFF; --hi6:#FFFFFF; --hi7:#FFFFFF;
|
||||
--radius: 4px; --shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
|
||||
--font-ui: "IBM Plex Sans", -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
||||
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
||||
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
||||
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
||||
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
||||
--bar:#5C8CFF; --bar-track:#1D2430;
|
||||
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
||||
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
||||
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
||||
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
||||
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
||||
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
||||
--bar:#5C8CFF; --bar-track:#1D2430;
|
||||
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
||||
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font-ui); }
|
||||
.shell { display: grid; grid-template-columns: 208px minmax(0,1fr); min-height: 100vh; }
|
||||
.side { background: var(--surface); border-right: 1px solid var(--border); padding: 16px 12px; display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
||||
.brand { font: 700 13px var(--font-mono); letter-spacing: .02em; padding: 4px 8px 14px; }
|
||||
.nav a { display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text-muted); font: 500 12.5px var(--font-mono); text-decoration: none; }
|
||||
.nav a.on { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.nav a:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
main { min-width: 0; padding: 20px 24px 40px; display: grid; gap: 16px; align-content: start; }
|
||||
.mock { background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning); border-radius: var(--radius); padding: 8px 12px; font-size: 12.5px; }
|
||||
h1 { font: 700 18px var(--font-mono); margin: 0; }
|
||||
h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
|
||||
.sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
|
||||
.head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
|
||||
.runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
select, .btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 10px; }
|
||||
select { min-width: 0; max-width: 100%; flex: 1 1 340px; }
|
||||
.btn { cursor: pointer; } .btn:hover { background: var(--surface-alt); }
|
||||
.btn[disabled] { opacity: .4; cursor: default; }
|
||||
.tabs { display: inline-flex; gap: 0; }
|
||||
.tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
|
||||
.tabs button + button { border-left: 0; }
|
||||
.tabs button:first-child { border-radius: var(--radius) 0 0 var(--radius); } .tabs button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--shadow); }
|
||||
.kpi .k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
||||
.kpi .v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.kpi .d { font-size: 12px; color: var(--text-muted); }
|
||||
.tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
|
||||
.tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
|
||||
.t-ok { background: var(--success-soft); color: var(--success); } .t-warn { background: var(--warning-soft); color: var(--warning); } .t-bad { background: var(--danger-soft); color: var(--danger); } .t-n { background: var(--neutral-soft); color: var(--neutral); }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
|
||||
.rows { display: grid; gap: 7px; }
|
||||
.bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.bar-row .n { overflow-wrap: anywhere; }
|
||||
.track { height: 12px; background: var(--bar-track); border-radius: 2px; position: relative; }
|
||||
.fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; }
|
||||
.num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.scroll { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th .hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
|
||||
th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
th.r, td.r { text-align: right; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||||
td.a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
|
||||
td.a a { color: var(--accent-strong); text-decoration: none; } td.a a:hover { text-decoration: underline; }
|
||||
.pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
|
||||
.pb .track { height: 8px; }
|
||||
.heat td.c { padding: 2px; }
|
||||
.cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
|
||||
.h1{background:var(--h1);color:var(--hi1)} .h2{background:var(--h2);color:var(--hi2)} .h3{background:var(--h3);color:var(--hi3)} .h4{background:var(--h4);color:var(--hi4)} .h5{background:var(--h5);color:var(--hi5)} .h6{background:var(--h6);color:var(--hi6)} .h7{background:var(--h7);color:var(--hi7)}
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
|
||||
.legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
|
||||
.vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
|
||||
.vals .col { flex: 1 1 0; min-width: 0; background: var(--bar); border-radius: 3px 3px 0 0; }
|
||||
.vals .avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
|
||||
.vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
|
||||
.dq { display: grid; gap: 8px; }
|
||||
.dq .row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
|
||||
.dq .row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
|
||||
.note { font-size: 12px; color: var(--text-muted); }
|
||||
button.kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
|
||||
button.kpi:hover { border-color: var(--accent); }
|
||||
button.kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.kpi .go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
|
||||
dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
|
||||
dialog[open] { display: flex; flex-direction: column; }
|
||||
dialog::backdrop { background: rgba(10,14,20,.5); }
|
||||
.dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
|
||||
.dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
|
||||
.dlg .note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
|
||||
.dlg .dist { flex: 0 0 auto; }
|
||||
.dlg .list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.dlg table { font-size: 12.5px; } .dlg th { position: sticky; top: 0; background: var(--surface); }
|
||||
.dlg .foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
|
||||
.dlg .foot .acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 520px) { .dlg { padding: 12px; gap: 10px; } .dlg .foot .acts { width: 100%; } .dlg .foot .acts .btn { flex: 1 1 auto; } }
|
||||
@media (max-height: 540px) { .dlg .dist { display: none; } }
|
||||
@media (max-height: 640px) { .dist .cols20 { height: 44px; } .dlg .note { max-height: 3em; } .dlg .dist .chips { display: none; } }
|
||||
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn.primary:hover { background: var(--accent-strong); }
|
||||
button.bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
|
||||
button.bar-row:hover { background: var(--surface-alt); }
|
||||
button.bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.dist { display: grid; gap: 8px; }
|
||||
.dist .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dist .cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
|
||||
.dist .cols20 div { background: var(--bar); border-radius: 2px 2px 0 0; min-height: 1px; }
|
||||
.dist .lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
|
||||
#tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: minmax(0,1fr); }
|
||||
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--border); padding: 10px 16px; }
|
||||
.brand { padding: 0 10px 0 0; } .nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
main { padding: 16px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #tip { transition: none; } }
|
||||
</style>
|
||||
|
||||
</head><body style="margin:0">
|
||||
<div class="shell">
|
||||
<aside class="side">
|
||||
<div class="brand">CLOUD IP VALIDATOR</div>
|
||||
<nav class="nav" aria-label="Разделы">
|
||||
<a href="#" onclick="return false">Обзор</a>
|
||||
<a href="#" onclick="return false">Очередь</a>
|
||||
<a href="#" onclick="return false">Реестр</a>
|
||||
<a href="#" class="on" aria-current="page" onclick="return false">Аналитика</a>
|
||||
<a href="#" onclick="return false">Валидаторы</a>
|
||||
<a href="#" onclick="return false">Настройки</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="mock" role="note"><b>Макет.</b> Запуск от 02.10 построен на реальных числах из отчётов <code>analysis/</code> (6440 адресов). Два других запуска и идущий — <b>демонстрационные</b>, нужны только для проверки переключателя.</div>
|
||||
|
||||
<div class="head">
|
||||
<h1>Аналитика</h1>
|
||||
<p class="sub">Один выбранный запуск. Данные других запусков на странице не участвуют.</p>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-label="Выбор запуска">
|
||||
<div class="runbar">
|
||||
<label for="run">Запуск</label>
|
||||
<button class="btn" id="prev" aria-label="Предыдущий запуск">◀</button>
|
||||
<select id="run"></select>
|
||||
<button class="btn" id="next" aria-label="Следующий запуск">▶</button>
|
||||
</div>
|
||||
<p class="note" id="runnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="cols">
|
||||
<section class="panel" aria-labelledby="h-reasons">
|
||||
<h2 id="h-reasons">Почему partial</h2>
|
||||
<div class="rows" id="reasons"></div>
|
||||
<p class="note">Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.</p>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="h-dq">
|
||||
<h2 id="h-dq">Качество данных</h2>
|
||||
<div class="dq" id="dq"></div>
|
||||
<p class="note">Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-labelledby="h-sub">
|
||||
<div class="head">
|
||||
<h2 id="h-sub">Подсети</h2>
|
||||
<div class="runbar">
|
||||
<div class="tabs" role="group" aria-label="Сортировка подсетей">
|
||||
<button type="button" data-sort="worst" aria-pressed="true">хуже всего</button>
|
||||
<button type="button" data-sort="size" aria-pressed="false">больше адресов</button>
|
||||
</div>
|
||||
<button class="btn" id="allsub" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll"><table id="subtbl"></table></div>
|
||||
<p class="note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="h-eg">
|
||||
<div class="head">
|
||||
<h2 id="h-eg">Egress по целям</h2>
|
||||
<div class="tabs" role="group" aria-label="Тип проверки">
|
||||
<button type="button" data-type="https" aria-pressed="true">https</button>
|
||||
<button type="button" data-type="icmp" aria-pressed="false">icmp</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rows" id="targets"></div>
|
||||
<div id="matrixwrap">
|
||||
<div class="head"><h2 style="margin-top:6px">Подсеть × цель</h2>
|
||||
<div class="legend" id="hlegend"></div></div>
|
||||
<div class="scroll"><table class="heat" id="matrix"></table></div>
|
||||
</div>
|
||||
<p class="note" id="matrixnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<section class="panel" aria-labelledby="h-in">
|
||||
<h2 id="h-in">Ingress по площадкам</h2>
|
||||
<div class="scroll"><table id="sites"></table></div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="h-err">
|
||||
<h2 id="h-err">Классы ошибок ingress</h2>
|
||||
<div class="rows" id="errs"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-labelledby="h-val">
|
||||
<h2 id="h-val">Валидаторы: доля провалов egress https</h2>
|
||||
<div class="vals" id="vals" role="img" aria-label="Доля проваленных https-проверок по 20 валидаторам"></div>
|
||||
<div class="vlab"><span>validator 1</span><span id="vavg"></span><span>validator 20</span></div>
|
||||
<p class="note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<dialog id="dlg" aria-labelledby="dlg-title">
|
||||
<div class="dlg">
|
||||
<h3 id="dlg-title"></h3>
|
||||
<p class="note" id="dlg-note"></p>
|
||||
<div class="dist" id="dlg-dist" hidden></div>
|
||||
<div class="list"><table id="dlg-tbl"></table></div>
|
||||
<div class="foot">
|
||||
<span class="note" id="dlg-msg" role="status"></span>
|
||||
<div class="acts">
|
||||
<button class="btn" type="button" id="dlg-copy">Копировать</button>
|
||||
<button class="btn primary" type="button" id="dlg-csv">Скачать CSV</button>
|
||||
<button class="btn" type="button" id="dlg-close" autofocus>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="tip" role="tooltip"></div>
|
||||
|
||||
<script>
|
||||
const REAL = {"subnets": [{"c": "37.139.32.0/22", "n": 327, "p": 240, "e": 240, "i": 327}, {"c": "185.130.112.0/22", "n": 360, "p": 169, "e": 169, "i": 360}, {"c": "161.104.104.0/22", "n": 452, "p": 124, "e": 124, "i": 452}, {"c": "87.239.104.0/21", "n": 369, "p": 115, "e": 115, "i": 365}, {"c": "212.233.72.0/21", "n": 471, "p": 105, "e": 105, "i": 460}, {"c": "212.111.84.0/22", "n": 218, "p": 102, "e": 102, "i": 215}, {"c": "146.185.240.0/22", "n": 197, "p": 81, "e": 81, "i": 190}, {"c": "83.166.248.0/21", "n": 659, "p": 71, "e": 82, "i": 598}, {"c": "89.208.196.0/22", "n": 165, "p": 60, "e": 60, "i": 164}, {"c": "89.208.84.0/22", "n": 151, "p": 58, "e": 60, "i": 140}, {"c": "213.219.212.0/22", "n": 84, "p": 53, "e": 53, "i": 84}, {"c": "91.219.226.0/23", "n": 106, "p": 52, "e": 53, "i": 93}, {"c": "89.208.208.0/22", "n": 157, "p": 50, "e": 50, "i": 157}, {"c": "37.139.40.0/22", "n": 108, "p": 50, "e": 50, "i": 108}, {"c": "90.156.216.0/22", "n": 110, "p": 46, "e": 46, "i": 110}, {"c": "79.137.174.0/23", "n": 117, "p": 43, "e": 43, "i": 117}, {"c": "185.86.144.0/22", "n": 105, "p": 43, "e": 43, "i": 105}, {"c": "212.233.88.0/21", "n": 237, "p": 42, "e": 45, "i": 230}, {"c": "95.163.208.0/22", "n": 210, "p": 41, "e": 41, "i": 210}, {"c": "217.16.24.0/22", "n": 79, "p": 39, "e": 53, "i": 70}, {"c": "95.163.212.0/22", "n": 118, "p": 35, "e": 35, "i": 118}, {"c": "90.156.212.0/22", "n": 236, "p": 32, "e": 32, "i": 235}, {"c": "84.23.52.0/22", "n": 154, "p": 32, "e": 32, "i": 154}, {"c": "85.192.32.0/22", "n": 75, "p": 31, "e": 31, "i": 74}, {"c": "212.233.120.0/22", "n": 131, "p": 30, "e": 30, "i": 126}, {"c": "83.166.232.0/21", "n": 263, "p": 29, "e": 30, "i": 227}, {"c": "185.241.192.0/22", "n": 90, "p": 25, "e": 25, "i": 85}, {"c": "94.139.244.0/22", "n": 69, "p": 25, "e": 26, "i": 69}, {"c": "217.16.16.0/21", "n": 32, "p": 23, "e": 27, "i": 20}, {"c": "5.188.140.0/22", "n": 22, "p": 22, "e": 22, "i": 22}, {"c": "217.16.28.0/22", "n": 28, "p": 18, "e": 18, "i": 28}, {"c": "89.208.216.0/24", "n": 34, "p": 17, "e": 17, "i": 34}, {"c": "109.120.188.0/22", "n": 14, "p": 11, "e": 11, "i": 14}, {"c": "89.208.220.0/22", "n": 144, "p": 10, "e": 10, "i": 144}, {"c": "95.163.182.0/23", "n": 132, "p": 10, "e": 11, "i": 117}, {"c": "212.233.96.0/22", "n": 13, "p": 6, "e": 6, "i": 13}, {"c": "89.208.228.0/22", "n": 12, "p": 6, "e": 6, "i": 12}, {"c": "109.120.180.0/22", "n": 91, "p": 5, "e": 6, "i": 80}, {"c": "90.156.150.0/23", "n": 10, "p": 4, "e": 4, "i": 10}, {"c": "95.163.180.0/23", "n": 36, "p": 3, "e": 5, "i": 24}, {"c": "146.185.208.0/22", "n": 8, "p": 3, "e": 3, "i": 8}, {"c": "95.163.248.0/22", "n": 1, "p": 1, "e": 1, "i": 1}, {"c": "161.104.108.0/23", "n": 45, "p": 0, "e": 0, "i": 45}], "matrix": [{"c": "83.166.248.0/21", "partial": 588, "v": [81, 57, 51, 86, 46]}, {"c": "212.233.72.0/21", "partial": 366, "v": [82, 65, 57, 0, 57]}, {"c": "161.104.104.0/22", "partial": 328, "v": [88, 53, 48, 0, 40]}, {"c": "87.239.104.0/21", "partial": 254, "v": [91, 37, 34, 56, 30]}, {"c": "83.166.232.0/21", "partial": 234, "v": [78, 58, 46, 89, 44]}, {"c": "90.156.212.0/22", "partial": 204, "v": [91, 48, 50, 69, 42]}, {"c": "212.233.88.0/21", "partial": 195, "v": [81, 70, 56, 0, 67]}, {"c": "185.130.112.0/22", "partial": 191, "v": [97, 19, 20, 0, 15]}, {"c": "95.163.208.0/22", "partial": 169, "v": [85, 51, 43, 42, 38]}, {"c": "89.208.220.0/22", "partial": 134, "v": [87, 54, 50, 78, 38]}, {"c": "95.163.182.0/23", "partial": 122, "v": [74, 69, 48, 73, 45]}, {"c": "84.23.52.0/22", "partial": 122, "v": [88, 63, 48, 80, 41]}, {"c": "212.111.84.0/22", "partial": 116, "v": [95, 23, 15, 0, 16]}, {"c": "146.185.240.0/22", "partial": 116, "v": [87, 38, 30, 7, 24]}, {"c": "89.208.208.0/22", "partial": 107, "v": [88, 43, 33, 57, 28]}, {"c": "89.208.196.0/22", "partial": 105, "v": [92, 36, 31, 52, 29]}, {"c": "212.233.120.0/22", "partial": 101, "v": [83, 62, 54, 0, 71]}, {"c": "89.208.84.0/22", "partial": 93, "v": [83, 33, 27, 47, 31]}, {"c": "37.139.32.0/22", "partial": 87, "v": [86, 33, 30, 57, 26]}, {"c": "109.120.180.0/22", "partial": 86, "v": [90, 67, 59, 28, 48]}, {"c": "95.163.212.0/22", "partial": 83, "v": [86, 39, 29, 14, 24]}, {"c": "79.137.174.0/23", "partial": 74, "v": [85, 41, 39, 59, 28]}, {"c": "185.241.192.0/22", "partial": 65, "v": [91, 48, 29, 0, 22]}, {"c": "90.156.216.0/22", "partial": 64, "v": [89, 38, 31, 50, 25]}, {"c": "185.86.144.0/22", "partial": 62, "v": [94, 37, 32, 0, 18]}, {"c": "37.139.40.0/22", "partial": 58, "v": [84, 29, 33, 52, 24]}, {"c": "91.219.226.0/23", "partial": 54, "v": [96, 33, 15, 35, 28]}, {"c": "161.104.108.0/23", "partial": 45, "v": [87, 82, 73, 0, 49]}, {"c": "94.139.244.0/22", "partial": 44, "v": [82, 27, 36, 41, 20]}, {"c": "85.192.32.0/22", "partial": 44, "v": [91, 27, 20, 36, 20]}, {"c": "217.16.24.0/22", "partial": 40, "v": [65, 2, 0, 0, 2]}, {"c": "95.163.180.0/23", "partial": 33, "v": [76, 61, 42, 82, 52]}, {"c": "213.219.212.0/22", "partial": 31, "v": [87, 61, 35, 0, 61]}, {"c": "Line truncated
|
||||
const TARGETS = ['packages.ubuntu.com','dl-cdn.alpinelinux.org','repo.almalinux.org','hub.docker.com','github.com'];
|
||||
const fmt = n => Math.round(n).toLocaleString('ru-RU');
|
||||
const pct = (a, b) => b ? Math.round(a / b * 100) : 0;
|
||||
const pct1 = (a, b) => b ? (a / b * 100).toLocaleString('ru-RU', {maximumFractionDigits: 1}) : '0';
|
||||
|
||||
/* Base run: real numbers. Demo runs: the same shape, scaled and shifted with a fixed pattern. */
|
||||
function baseRun() {
|
||||
const subs = REAL.subnets.map(s => ({c: s.c, n: s.n, pass: s.p, eg: s.e, ing: s.i}));
|
||||
return {
|
||||
id: 'r3', demo: false, label: '02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass', kind: 'ручной (скан Floating IP)',
|
||||
start: '02.10.2026 13:47', end: '02.10.2026 22:29', dur: '8 ч 42 мин', rechecked: 9,
|
||||
subs, matrix: REAL.matrix.map(m => ({c: m.c, partial: m.partial, v: m.v.slice()})),
|
||||
reasons: [['Только egress', 4146], ['Ingress и egress', 157], ['Egress и неполный набор', 125], ['Ingress, egress и неполный набор', 9], ['Только неполный набор', 33], ['Только ingress', 8]],
|
||||
late: 246, lateAddr: 50, incomplete: 167, mismatch: 50,
|
||||
errLate: 844, egHttpsAny: 4409, egHttpsAll: 307, egHttpsAllFull: 290, ingSshAll: 7, ingSshAny: 222,
|
||||
targets: {https: [3841, 2193, 1872, 1805, 1728], icmp: [79, 0, 0, 1, 0]},
|
||||
sites: [['rxmsk', 6319, 6165, 6319, 6128, 6319, 6179], ['rxyc', 6439, 6435, 6439, 6429, 6439, 6434], ['misha-v', 6364, 6257, 6364, 6207, 6364, 6266], ['vk-kaz', 6426, 6400, 6426, 6392, 6426, 6402]],
|
||||
errs: [['SSH: таймаут', 327], ['ICMP: нет ответа', 276], ['TCP-22: таймаут', 260], ['SSH: баннер «Not allowed»', 47], ['SSH: нет маршрута', 18], ['ICMP: time exceeded', 15], ['TCP-22: нет маршрута', 7]],
|
||||
vals: [[1595,999],[1630,1071],[1618,1046],[1613,1042],[1597,998],[1622,1093],[1615,1045],[1599,1012],[1594,991],[1602,1049],[1610,1043],[1595,1014],[1630,1069],[1576,974],[1634,1073],[1589,1006],[1620,1062],[1613,1021],[1610,1047],[1614,1082]]
|
||||
};
|
||||
}
|
||||
function variant(b, id, label, kind, start, end, dur, scale, phase, shift) {
|
||||
const r = JSON.parse(JSON.stringify(b));
|
||||
Object.assign(r, {id, demo: true, label, kind, start, end, dur, rechecked: 0});
|
||||
r.subs = b.subs.map((s, i) => {
|
||||
const k = 1 + 0.22 * Math.sin(i * 1.7 + phase);
|
||||
const n = Math.max(1, Math.round(s.n * scale * (1 + 0.1 * Math.cos(i + phase))));
|
||||
const pass = Math.min(n, Math.max(0, Math.round(n * (s.pass / s.n) * k + n * shift)));
|
||||
return {c: s.c, n, pass, eg: Math.min(n, Math.round(n * (s.eg / s.n) * k + n * shift)), ing: Math.min(n, Math.round(n * (s.ing / s.n)))};
|
||||
});
|
||||
const total = r.subs.reduce((a, s) => a + s.n, 0);
|
||||
const f = total / 6440;
|
||||
r.matrix = b.matrix.map((m, i) => ({c: m.c, partial: Math.max(1, Math.round(m.partial * scale)), v: m.v.map((x, j) => Math.max(0, Math.min(100, Math.round(x * (1 + 0.2 * Math.sin(i + j + phase)) + shift * 100))))}));
|
||||
r.reasons = b.reasons.map(([k, v]) => [k, Math.round(v * f)]);
|
||||
r.late = Math.round(b.late * f); r.lateAddr = Math.round(b.lateAddr * f); r.incomplete = Math.round(b.incomplete * f); r.mismatch = Math.round(b.mismatch * f);
|
||||
r.errLate = Math.round(b.errLate * f); r.egHttpsAny = Math.round(b.egHttpsAny * f); r.egHttpsAll = Math.round(b.egHttpsAll * f); r.egHttpsAllFull = Math.round(b.egHttpsAllFull * f); r.ingSshAll = Math.round(b.ingSshAll * f); r.ingSshAny = Math.round(b.ingSshAny * f);
|
||||
r.targets = {https: b.targets.https.map(x => Math.round(x * f * (1 - shift * 2))), icmp: b.targets.icmp.map(x => Math.round(x * f))};
|
||||
r.sites = b.sites.map(s => s.map((x, j) => j === 0 ? x : Math.round(x * f)));
|
||||
r.errs = b.errs.map(([k, v]) => [k, Math.round(v * f)]);
|
||||
r.vals = b.vals.map(([t, o]) => [Math.round(t * f), Math.round(o * f * (1 + shift * 0.5))]);
|
||||
return r;
|
||||
}
|
||||
const BASE = baseRun();
|
||||
const RUNS = [
|
||||
variant(BASE, 'r1', '01.10 07:59 → 08:41 · авто · демо', 'автоцикл (демо)', '01.10.2026 07:59', '01.10.2026 08:41', '42 мин', 0.04, 0.6, 0.12),
|
||||
variant(BASE, 'r2', '03.10 10:05 → 12:20 · ручной · демо', 'перепроверка (демо)', '03.10.2026 10:05', '03.10.2026 12:20', '2 ч 15 мин', 0.16, 2.1, 0.06),
|
||||
BASE
|
||||
];
|
||||
const OPEN = {label: '03.10 15:30 → идёт · ручной · 120 из 800 · недоступен', disabled: true};
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const state = {run: RUNS[RUNS.length - 1], sort: 'worst', all: false, type: 'https'};
|
||||
|
||||
function sum(run) {
|
||||
const n = run.subs.reduce((a, s) => a + s.n, 0), pass = run.subs.reduce((a, s) => a + s.pass, 0);
|
||||
const eg = run.subs.reduce((a, s) => a + s.eg, 0), ing = run.subs.reduce((a, s) => a + s.ing, 0);
|
||||
return {n, pass, partial: n - pass, eg, ing};
|
||||
}
|
||||
function heatClass(v) { return 'h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
|
||||
function bar(v, max) { return `<div class="track"><div class="fill" style="width:${max ? Math.max(1, v / max * 100) : 0}%"></div></div>`; }
|
||||
function tipAttr(t) { return `data-tip="${t.replace(/"/g, '"')}"`; }
|
||||
|
||||
function render() {
|
||||
const r = state.run, s = sum(r);
|
||||
$('run').value = r.id;
|
||||
$('prev').disabled = RUNS.indexOf(r) === 0; $('next').disabled = RUNS.indexOf(r) === RUNS.length - 1;
|
||||
$('runnote').innerHTML = `Тип: ${r.kind}. Начало ${r.start}, завершён ${r.end}, длительность ${r.dur}.` + (r.rechecked ? ` Перепроверено внутри запуска: ${r.rechecked} адр. (берётся последний цикл).` : '') + (r.demo ? ' <b>Демонстрационные данные.</b>' : '');
|
||||
$('kpis').innerHTML = [
|
||||
['Адресов', fmt(s.n), 'последний цикл каждого адреса', ''],
|
||||
['pass', fmt(s.pass), `${pct(s.pass, s.n)}% адресов`, '<span class="tag t-ok">успех</span>'],
|
||||
['partial', fmt(s.partial), `${pct(s.partial, s.n)}% адресов`, '<span class="tag t-warn">частично</span>'],
|
||||
['fail', '0', 'ни одной полностью проваленной', '<span class="tag t-n">нет</span>'],
|
||||
['Egress OK', pct1(s.eg, s.n) + '%', 'все egress-проверки адреса успешны', ''],
|
||||
['Ingress OK', pct1(s.ing, s.n) + '%', 'все ingress-проверки адреса успешны', ''],
|
||||
['Egress https: есть провалы', fmt(r.egHttpsAny), `${pct1(r.egHttpsAny, s.n)}% адресов, хотя бы одна цель недоступна`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'egany'],
|
||||
['Ingress ssh: есть провалы', fmt(r.ingSshAny), `${pct1(r.ingSshAny, s.n)}% адресов, хотя бы с одной площадки`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'sshany'],
|
||||
['Egress https: все провалены', fmt(r.egHttpsAll), `${pct1(r.egHttpsAll, s.n)}% адресов, по всем 5 целям: ${fmt(r.egHttpsAllFull)}`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'eg'],
|
||||
['Ingress ssh: все провалены', fmt(r.ingSshAll), `${pct1(r.ingSshAll, s.n)}% адресов, ssh провален со всех площадок`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'ssh'],
|
||||
['Длительность', r.dur, `${fmt(s.n / Math.max(1, parseDur(r.dur)))} адр./мин`, ''],
|
||||
['Поздние результаты', fmt(r.late), `у ${fmt(r.lateAddr)} адресов, после вердикта`, '<span class="tag t-warn">внимание</span>']
|
||||
].map(([k, v, d, t, key]) => { const inner = `<div class="k">${k}</div><div class="v">${v}</div><div class="d">${d} ${t}</div>`; return key ? `<button type="button" class="kpi" data-list="${key}" aria-haspopup="dialog">${inner}</button>` : `<div class="kpi">${inner}</div>`; }).join('');
|
||||
|
||||
const rmax = Math.max(...r.reasons.map(x => x[1]));
|
||||
$('reasons').innerHTML = r.reasons.map(([k, v]) => `<div class="bar-row" ${tipAttr(k + ': ' + fmt(v) + ' адр. (' + pct(v, s.partial) + '% от partial)')}><span class="n">${k}</span>${bar(v, rmax)}<span class="num">${fmt(v)}</span></div>`).join('');
|
||||
$('dq').innerHTML = [
|
||||
['Поздние провалы у адресов с вердиктом pass', `${fmt(r.late)} / ${fmt(r.lateAddr)} адр.`],
|
||||
['Провалы ingress, пришедшие после вердикта', `${fmt(r.errLate)} из ${fmt(r.errs.reduce((a, e) => a + e[1], 0))}`],
|
||||
['Неполный набор проверок', `${fmt(r.incomplete)} адр.`],
|
||||
['Вердикт pass, но есть проваленные проверки', `${fmt(r.mismatch)} адр.`],
|
||||
['pass по вердикту → по фактическим проверкам', `${fmt(s.pass)} → ${fmt(s.pass - r.mismatch)}`]
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><span>${v}</span></div>`).join('');
|
||||
|
||||
renderSubnets(r); renderTargets(r); renderSites(r); renderErrs(r); renderVals(r);
|
||||
}
|
||||
function parseDur(d) { const h = /(\d+) ч/.exec(d), m = /(\d+) мин/.exec(d); return (h ? +h[1] * 60 : 0) + (m ? +m[1] : 0) || 1; }
|
||||
|
||||
function pbar(a, n) { const p = pct(a, n); return `<div class="pb"><div class="track">${'<div class="fill" style="width:' + p + '%"></div>'}</div><span class="num">${p}%</span></div>`; }
|
||||
function renderSubnets(r) {
|
||||
let list = r.subs.slice();
|
||||
list.sort(state.sort === 'worst' ? (a, b) => a.pass / a.n - b.pass / b.n || b.n - a.n : (a, b) => b.n - a.n);
|
||||
const shown = state.all ? list : list.slice(0, 10);
|
||||
$('allsub').textContent = state.all ? 'свернуть до 10' : `показать все ${list.length}`;
|
||||
$('subtbl').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">Адресов</th><th class="r">pass</th><th class="r">Egress OK</th><th class="r">Ingress OK</th></tr></thead><tbody>` +
|
||||
shown.map(s => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть в реестре: запуск ${r.id}, подсеть ${s.c}">${s.c}</a></td><td class="num r">${fmt(s.n)}</td><td class="r" ${tipAttr(s.c + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.n))}>${pbar(s.pass, s.n)}</td><td class="r" ${tipAttr(s.c + ': egress OK ' + fmt(s.eg) + ' из ' + fmt(s.n))}>${pbar(s.eg, s.n)}</td><td class="r" ${tipAttr(s.c + ': ingress OK ' + fmt(s.ing) + ' из ' + fmt(s.n))}>${pbar(s.ing, s.n)}</td></tr>`).join('') + '</tbody>';
|
||||
}
|
||||
function renderTargets(r) {
|
||||
const t = state.type, vals = r.targets[t], s = sum(r), max = Math.max(...vals, 1);
|
||||
$('targets').innerHTML = TARGETS.map((n, i) => `<div class="bar-row" ${tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], s.n) + '% всех)')}><span class="n">${n}</span>${bar(vals[i], max)}<span class="num">${fmt(vals[i])} · ${pct(vals[i], s.n)}%</span></div>`).join('');
|
||||
if (t === 'https') {
|
||||
$('matrixwrap').hidden = false;
|
||||
$('hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map((l, i) => `<span><i style="background:var(--h${i + 1})"></i> ${l}%</span>`).join('');
|
||||
const list = r.matrix.slice().sort((a, b) => b.partial - a.partial).slice(0, state.all ? 99 : 10);
|
||||
$('matrix').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">partial</th>${TARGETS.map(x => `<th class="r">${x.replace('.com', '').replace('.org', '')}</th>`).join('')}</tr></thead><tbody>` +
|
||||
list.map(m => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть адреса в реестре">${m.c}</a></td><td class="num r">${fmt(m.partial)}</td>${m.v.map((v, j) => `<td class="c"><span class="cell ${heatClass(v)}" ${tipAttr(m.c + ' → ' + TARGETS[j] + ': ' + v + '% адресов partial провалили https')}>${v}%</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
||||
$('matrixnote').textContent = 'Доля адресов partial подсети, проваливших https к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
|
||||
} else {
|
||||
$('matrixwrap').hidden = true;
|
||||
$('matrixnote').textContent = 'Тип icmp: провалов почти нет, почти все относятся к packages.ubuntu.com. Матрица по подсетям для icmp в макете не показана, в рабочей странице она строится для любого типа.';
|
||||
}
|
||||
}
|
||||
function renderSites(r) {
|
||||
$('sites').innerHTML = `<thead><tr><th>Площадка</th><th class="r">icmp</th><th class="r">ssh</th><th class="r">tcp</th></tr></thead><tbody>` +
|
||||
r.sites.map(s => `<tr><td class="a">${s[0]}</td>${[1, 3, 5].map(i => `<td class="r"><span class="cell h${heatClass(100 - pct(s[i + 1], s[i]) * 8 > 100 ? 100 : Math.min(100, (100 - pct(s[i + 1], s[i])) * 8)).slice(1)}" ${tipAttr(s[0] + ' · ' + ['icmp', 'ssh', 'tcp'][(i - 1) / 2] + ': ' + fmt(s[i + 1]) + ' из ' + fmt(s[i]) + ' успешно')}>${fmt(s[i + 1])} / ${fmt(s[i])}</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
||||
}
|
||||
function renderErrs(r) {
|
||||
const max = Math.max(...r.errs.map(e => e[1]), 1);
|
||||
$('errs').innerHTML = r.errs.map(([k, v]) => `<button type="button" class="bar-row" data-cls="${k}" aria-haspopup="dialog" ${tipAttr(k + ': ' + fmt(v) + ' проверок. Нажмите, чтобы открыть список')}><span class="n">${k}</span>${bar(v, max)}<span class="num">${fmt(v)}</span></button>`).join('');
|
||||
}
|
||||
function renderVals(r) {
|
||||
const f = r.vals.map(([t, o]) => t ? (1 - o / t) : 0), avg = f.reduce((a, b) => a + b, 0) / f.length;
|
||||
$('vals').innerHTML = f.map((x, i) => `<div class="col" style="height:${x * 100}%" ${tipAttr('validator ' + (i + 1) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(r.vals[i][0]) + ' https-проверок')}></div>`).join('') + `<div class="avg" style="bottom:${avg * 100}%"></div>`;
|
||||
$('vavg').textContent = `среднее ${Math.round(avg * 100)}% (шкала 0–100%)`;
|
||||
}
|
||||
|
||||
/* controls */
|
||||
const sel = $('run');
|
||||
sel.innerHTML = RUNS.slice().reverse().map(r => `<option value="${r.id}">${r.label}</option>`).join('') + `<option disabled>${OPEN.label}</option>`;
|
||||
sel.addEventListener('change', () => { state.run = RUNS.find(r => r.id === sel.value); render(); });
|
||||
$('prev').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i > 0) { state.run = RUNS[i - 1]; render(); } });
|
||||
$('next').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i < RUNS.length - 1) { state.run = RUNS[i + 1]; render(); } });
|
||||
document.querySelectorAll('[data-sort]').forEach(b => b.addEventListener('click', () => { state.sort = b.dataset.sort; document.querySelectorAll('[data-sort]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
||||
document.querySelectorAll('[data-type]').forEach(b => b.addEventListener('click', () => { state.type = b.dataset.type; document.querySelectorAll('[data-type]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
||||
$('allsub').addEventListener('click', () => { state.all = !state.all; render(); });
|
||||
|
||||
|
||||
/* modal: address lists (two indicators) and failed checks of one ingress error class */
|
||||
const LISTS = {
|
||||
egany: {title: 'Egress https: адреса с проваленными проверками', file: 'egress-https-any-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'Провалено https', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.'}, rows: () => REAL.egAnyList, n: r => r.egHttpsAny,
|
||||
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.'},
|
||||
sshany: {title: 'Ingress ssh: адреса с проваленными проверками', file: 'ingress-ssh-any-failed', cols: ['Адрес', 'Подсеть', 'Провалено ssh', 'Площадки с провалом', 'Ошибка'], hints: {2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshAnyList, n: r => r.ingSshAny,
|
||||
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.'},
|
||||
eg: {title: 'Egress https: адреса, провалившие все проверки', file: 'egress-https-all-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'https-проверок', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше 5 значит, что часть результатов не пришла.'}, rows: () => REAL.egList, n: r => r.egHttpsAll,
|
||||
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.'},
|
||||
ssh: {title: 'Ingress ssh: адреса, провалившие все проверки', file: 'ingress-ssh-all-failed', cols: ['Адрес', 'Подсеть', 'Площадки с провалом ssh', 'Ошибка'], hints: {2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshList, n: r => r.ingSshAll,
|
||||
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.'}
|
||||
};
|
||||
const VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
|
||||
const STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
|
||||
const VERDICT_VAL = {pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.', partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.', fail: 'fail: все проверки адреса провалены.'};
|
||||
const STATUS_VAL = v => String(v).includes('после') ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.';
|
||||
const ERRCOLS = ['Адрес', 'Подсеть', 'Площадка', 'Валидатор', 'Вердикт адреса', 'Статус проверки'];
|
||||
let cur = null;
|
||||
function showModal(m) {
|
||||
cur = m;
|
||||
$('dlg-title').textContent = `${m.title} · ${fmt(m.rows.length)}`;
|
||||
$('dlg-note').textContent = `Запуск: ${m.r.label}. ${m.note}` + (m.r.demo ? ' Демонстрационный запуск: список — первые строки реального запуска от 02.10.' : '');
|
||||
$('dlg-dist').hidden = !m.dist; $('dlg-dist').innerHTML = m.dist || '';
|
||||
const hint = (m.hints || {});
|
||||
$('dlg-tbl').innerHTML = `<thead><tr>${m.cols.map((c, i) => hint[i] ? `<th ${tipAttr(hint[i])} tabindex="0">${c}<span class="hint" aria-hidden="true">ⓘ</span></th>` : `<th>${c}</th>`).join('')}</tr></thead><tbody>` + m.rows.map(x => `<tr>${x.map((v, i) => { const h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : ''; return `<td class="${i < 2 ? 'a' : ''}"${h ? ' ' + tipAttr(h) : ''}>${v}</td>`; }).join('')}</tr>`).join('') + '</tbody>';
|
||||
$('dlg-msg').textContent = '';
|
||||
const d = $('dlg'); if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
||||
}
|
||||
function openList(key) {
|
||||
const L = LISTS[key], r = state.run, all = L.rows(), n = Math.min(all.length, L.n(r));
|
||||
showModal({title: L.title, note: L.note, file: L.file, cols: L.cols, hints: L.hints, rows: r.demo ? all.slice(0, n) : all, r});
|
||||
}
|
||||
function openErr(cls) {
|
||||
const r = state.run, n = (r.errs.find(e => e[0] === cls) || [0, 0])[1];
|
||||
const all = REAL.errRows.filter(x => x[0] === cls), rows = (r.demo ? all.slice(0, n) : all).map(x => x.slice(1));
|
||||
const byVal = new Array(20).fill(0); rows.forEach(x => { byVal[+x[3].slice(1) - 1]++; });
|
||||
const max = Math.max(...byVal, 1), late = rows.filter(x => x[5] === 1).length, pass = rows.filter(x => x[4] === 'pass').length;
|
||||
const dist = `<h2>Распределение по валидаторам</h2>
|
||||
<div class="cols20" role="img" aria-label="Число проваленных проверок по валидаторам">${byVal.map((v, i) => `<div style="height:${Math.max(1, v / max * 100)}%" ${tipAttr('валидатор ' + (i + 1) + ': ' + v + ' проверок')}></div>`).join('')}</div>
|
||||
<div class="lab20">${byVal.map((v, i) => `<span>${i + 1}</span>`).join('')}</div>
|
||||
<div class="chips"><span class="tag t-n">всего ${fmt(rows.length)}</span><span class="tag t-ok">вердикт pass: ${fmt(pass)}</span><span class="tag t-warn">вердикт partial: ${fmt(rows.length - pass)}</span><span class="tag t-bad">после вердикта: ${fmt(late)}</span><span class="tag t-n">в вердикте: ${fmt(rows.length - late)}</span></div>`;
|
||||
const view = rows.map(x => [x[0], x[1], x[2], x[3], x[4], x[5] ? 'провал, после вердикта' : 'провал, в вердикте']);
|
||||
showModal({title: 'Ingress: ' + cls, note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
|
||||
file: 'ingress-errors-' + cls.toLowerCase().replace(/[^a-z0-9а-я]+/g, '-').replace(/^-|-$/g, ''), cols: ERRCOLS, rows: view, r, dist,
|
||||
hints: {3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT},
|
||||
cellHints: {4: v => VERDICT_VAL[v] || '', 5: STATUS_VAL}});
|
||||
}
|
||||
function csvText(cols, rows) {
|
||||
const q = v => '"' + String(v).replace(/"/g, '""') + '"';
|
||||
return '\ufeff' + [cols, ...rows].map(row => row.map(q).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
async function saveCsv() {
|
||||
const name = `${cur.file}_${cur.r.id}.csv`, text = csvText(cur.cols, cur.rows), msg = $('dlg-msg');
|
||||
let dl = null;
|
||||
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
|
||||
if (dl) {
|
||||
try { await dl.save({filename: name, data: text}); msg.textContent = 'Файл ' + name + ' сохранён.'; }
|
||||
catch (e) { msg.textContent = e && e.code === 'declined' ? 'Сохранение отменено.' : 'Сохранить не удалось. Используйте «Копировать».'; }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], {type: 'text/csv;charset=utf-8'})); a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove(); msg.textContent = 'Файл ' + name + ' отправлен на скачивание.';
|
||||
} catch (e) { msg.textContent = 'Сохранить не удалось. Используйте «Копировать».'; }
|
||||
}
|
||||
async function copyCsv() {
|
||||
const msg = $('dlg-msg');
|
||||
try { await navigator.clipboard.writeText(csvText(cur.cols, cur.rows).replace('\ufeff', '')); msg.textContent = 'CSV скопирован в буфер обмена.'; }
|
||||
catch (e) { msg.textContent = 'Буфер обмена недоступен в этом окне.'; }
|
||||
}
|
||||
$('kpis').addEventListener('click', e => { const b = e.target.closest('[data-list]'); if (b) openList(b.dataset.list); });
|
||||
$('errs').addEventListener('click', e => { const b = e.target.closest('[data-cls]'); if (b) openErr(b.dataset.cls); });
|
||||
$('dlg-csv').addEventListener('click', saveCsv);
|
||||
$('dlg-copy').addEventListener('click', copyCsv);
|
||||
$('dlg-close').addEventListener('click', () => $('dlg').close());
|
||||
$('dlg').addEventListener('click', e => { if (e.target === $('dlg')) $('dlg').close(); });
|
||||
|
||||
/* tooltip (moved into the open dialog, otherwise the modal's top layer would cover it) */
|
||||
const tip = $('tip');
|
||||
function tipHost() { const d = $('dlg'); return d && d.open ? d : document.body; }
|
||||
document.addEventListener('mouseover', e => { const t = e.target.closest('[data-tip]'); if (t) { const h = tipHost(); if (tip.parentNode !== h) h.appendChild(tip); tip.textContent = t.dataset.tip; tip.style.opacity = 1; } });
|
||||
document.addEventListener('mousemove', e => { tip.style.left = Math.max(8, Math.min(e.clientX + 14, innerWidth - 376)) + 'px'; tip.style.top = (e.clientY + 16) + 'px'; });
|
||||
document.addEventListener('mouseout', e => { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
Reference in new issue
Block a user