diff --git a/README.md b/README.md
index 201dfc0..0e25c6a 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,12 @@ SSH до внешних целей) и входящая доступность
каждому адресу — `pass`/`partial`/`fail`, с полной историей проверок в
базе данных.
-Три компонента: `control-api` (управляющий сервис, единственный со
+Четыре компонента: `control-api` (управляющий сервис, единственный со
состоянием), `validator-agent` (работает на каждой ВМ-валидаторе, без
-состояния) и `prober` (работает на каждой из трёх внешних площадок, без
-состояния). Все три общаются между собой только через HTTP API
-control-api.
+состояния), `prober` (работает на каждой из трёх внешних площадок, без
+состояния) и `admin-dashboard` (браузерная веб-панель администратора, без
+состояния, опциональна). Все четыре общаются между собой только через
+HTTP API control-api.
## Документация
@@ -22,6 +23,7 @@ control-api.
| [docs/SETUP.md](docs/SETUP.md) | Развёртывание из готовых бинарников (`bin/`) или сборка из исходников, конфигурация, первый запуск стенда — с нуля |
| [docs/USAGE.md](docs/USAGE.md) | Повседневная работа: постановка адресов в очередь, наблюдение за статусом, разбор результатов |
| [docs/API.md](docs/API.md) | Спецификация HTTP API control-api и примеры запросов (curl) |
+| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Браузерная админ-панель (`admin-dashboard`) — то же самое API, но графически |
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, поток проверки до целевого сервера, поток телеметрии |
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом — без реального облака и интернета |
diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS
index 2dd96bc..a3d346c 100644
--- a/bin/SHA256SUMS
+++ b/bin/SHA256SUMS
@@ -1,3 +1,4 @@
-ea275ac1b75e2b1aa16f8c2424be04983fda12b23bc99866e2b56e2dc25dda31 control-api
-aa82eca68cf5abb6e91091418c728fa4407d306126d0915799cacf7c6c2a1dac validator-agent
-645c69e72d5fcd2ff385eaa558c0e5849f5ee28ffbbc46090dd3be6244b7e36c prober
+992ec763b2dc26f87d5391e072904e5897863a0c4c5b27c3c4980aa03f037c5e control-api
+48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent
+43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober
+fcf80759c37b8b7ea572d313e6689e7c7d00c1369f0889ed24b9eea16c0a17b0 admin-dashboard
diff --git a/bin/admin-dashboard b/bin/admin-dashboard
new file mode 100755
index 0000000..9575626
Binary files /dev/null and b/bin/admin-dashboard differ
diff --git a/bin/control-api b/bin/control-api
index d37fcf3..312b065 100755
Binary files a/bin/control-api and b/bin/control-api differ
diff --git a/bin/prober b/bin/prober
index f906b4a..5a6d5b3 100755
Binary files a/bin/prober and b/bin/prober differ
diff --git a/bin/validator-agent b/bin/validator-agent
index 864d8cb..5a1bb75 100755
Binary files a/bin/validator-agent and b/bin/validator-agent differ
diff --git a/cmd/admin-dashboard/main.go b/cmd/admin-dashboard/main.go
new file mode 100644
index 0000000..012f14d
--- /dev/null
+++ b/cmd/admin-dashboard/main.go
@@ -0,0 +1,71 @@
+// Command admin-dashboard is a stateless, server-rendered web UI over
+// control-api's /api/v1/admin/* HTTP API — see docs/DASHBOARD.md. It never
+// connects to the database and holds no state of its own.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "cloudipvalidator/internal/config"
+ "cloudipvalidator/internal/dashboard"
+)
+
+func main() {
+ configPath := flag.String("config", "configs/admin-dashboard.yaml", "path to admin-dashboard config file")
+ flag.Parse()
+
+ log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
+
+ if err := run(*configPath, log); err != nil {
+ log.Error("fatal", "err", err)
+ os.Exit(1)
+ }
+}
+
+func run(configPath string, log *slog.Logger) error {
+ cfg, err := config.LoadAdminDashboard(configPath)
+ if err != nil {
+ return fmt.Errorf("load config: %w", err)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ srv, err := dashboard.New(dashboard.Config{
+ ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
+ ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
+ LastCompletedCount: cfg.Overview.LastCompletedCount,
+ OverviewPollIntervalS: cfg.Overview.PollIntervalSeconds,
+ }, log)
+ if err != nil {
+ return fmt.Errorf("init dashboard: %w", err)
+ }
+
+ httpServer := &http.Server{Addr: cfg.Server.ListenAddr, Handler: srv.Handler()}
+
+ errCh := make(chan error, 1)
+ go func() {
+ log.Info("listening", "addr", cfg.Server.ListenAddr, "control_api", cfg.ControlAPI.BaseURL)
+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ errCh <- err
+ }
+ }()
+
+ select {
+ case <-ctx.Done():
+ log.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return httpServer.Shutdown(shutdownCtx)
+ case err := <-errCh:
+ return err
+ }
+}
diff --git a/cmd/control-api/main.go b/cmd/control-api/main.go
index 2bb6405..a1d586e 100644
--- a/cmd/control-api/main.go
+++ b/cmd/control-api/main.go
@@ -49,13 +49,8 @@ func run(configPath string, log *slog.Logger) error {
}
defer database.Close()
- for _, v := range cfg.Validators {
- if err := database.RegisterValidator(ctx, v.ValidatorID, "", v.OSPortID, ""); err != nil {
- return fmt.Errorf("seed validator %s: %w", v.ValidatorID, err)
- }
- }
- if err := database.SeedQueue(ctx, cfg.IPAddresses); err != nil {
- return fmt.Errorf("seed ip queue: %w", err)
+ if err := database.BootstrapFromConfig(ctx, cfg); err != nil {
+ return fmt.Errorf("bootstrap database: %w", err)
}
osClient, err := newOpenStackClient(ctx, cfg)
diff --git a/configs/admin-dashboard.example.yaml b/configs/admin-dashboard.example.yaml
new file mode 100644
index 0000000..e2699f6
--- /dev/null
+++ b/configs/admin-dashboard.example.yaml
@@ -0,0 +1,18 @@
+server:
+ listen_addr: ":8090"
+
+control_api:
+ base_url: "http://control-api.internal:8080"
+ timeout_seconds: 10
+
+# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
+# влияют только на то, как дашборд группирует уже существующие данные
+# control-api (GET /admin/status, GET /admin/ips); никакого нового
+# состояния control-api не заводит.
+overview:
+ # Сколько последних завершённых (done/failed) адресов показывать в
+ # сводке "последняя завершённая проверка" и учитывать в разбивке
+ # pass/partial/fail/cancelled.
+ last_completed_count: 20
+ # Как часто браузер опрашивает /overview/fragment для live-обновления.
+ poll_interval_seconds: 5
diff --git a/deploy/systemd/admin-dashboard.service b/deploy/systemd/admin-dashboard.service
new file mode 100644
index 0000000..ff2a9a3
--- /dev/null
+++ b/deploy/systemd/admin-dashboard.service
@@ -0,0 +1,18 @@
+[Unit]
+Description=Cloud IP Validator - Admin Dashboard
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=cloud-ip-validator
+Group=cloud-ip-validator
+ExecStart=/usr/local/bin/admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
+Restart=on-failure
+RestartSec=5
+NoNewPrivileges=true
+ProtectSystem=strict
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/API.md b/docs/API.md
index d291369..471ab6d 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -6,7 +6,10 @@ JSON, базовый префикс прикладных методов — `/ap
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
> — эндпоинты доступны любому, кто может достучаться до порта control-api
-> по сети. Для эксплуатации за пределами доверенного сегмента сети
+> по сети. Это касается и методов из раздела
+> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
+> ниже — они меняют, что и как проверяется, без подтверждения личности
+> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
> обязательно ограничьте доступ на уровне сети/файрвола (см.
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
> направление доработки, в текущей версии не реализовано.
@@ -14,12 +17,17 @@ JSON, базовый префикс прикладных методов — `/ap
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
+> Для работы из браузера вместо `curl` есть `admin-dashboard` — веб-панель,
+> дающая графический доступ ко всему административному API ниже, см.
+> [DASHBOARD.md](DASHBOARD.md).
+
## Содержание
- [Общие соглашения](#общие-соглашения)
- [Методы для validator-agent](#методы-для-validator-agent)
- [Методы для prober](#методы-для-prober)
- [Служебные и административные методы](#служебные-и-административные-методы)
+- [Управление очередью и конфигурацией](#управление-очередью-и-конфигурацией)
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
@@ -37,9 +45,11 @@ JSON, базовый префикс прикладных методов — `/ap
не удалось распарсить, сервер молча подставит текущее время сервера — не
полагайтесь на это в продакшене, всегда передавайте валидную метку.
- `validator_id` и `site_id` в пути запроса должны совпадать со
- значениями, заданными в конфиге control-api (`validators[].validator_id`,
- `sites[].site_id`) — иначе методы, требующие существующую сущность,
- вернут `404`.
+ значениями, известными control-api — заданными в `control-api.yaml`
+ при первом запуске (пустая база) либо созданными позже через
+ `/api/v1/admin/config/*` (см.
+ [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией))
+ — иначе методы, требующие существующую сущность, вернут `404`.
## Методы для validator-agent
@@ -297,6 +307,152 @@ IP на данном проходе". До этого момента control-api
`assigned`, `checking`, `unreachable`) и `CurrentIPID`, если валидатор
сейчас занят.
+## Управление очередью и конфигурацией
+
+Методы этого раздела — единственный способ менять состав очереди
+(`ip_addresses`), список валидаторов, площадок (`sites`) и целей
+проверки (`targets`/`check_types`) **без остановки процесса**: изменения
+применяются немедленно и переживают последующий рестарт control-api. Все
+тела запросов/ответов — `snake_case` (в отличие от `GET
+/admin/status|ips|validators` выше, которые отдают сырые поля Go-структур
+в PascalCase — эти два стиля сосуществуют осознанно, см. примечание к
+`GET /api/v1/admin/ips/{ip}`).
+
+**Источник истины.** `control-api.yaml` используется только как
+одноразовый bootstrap для пустой базы данных: секции `validators`,
+`sites`, `targets`, `check_types` читаются из YAML один раз, при самом
+первом старте на пустых таблицах. Как только в соответствующей таблице
+появилась хотя бы одна строка (через bootstrap либо через методы ниже) —
+YAML для этой секции больше не перечитывается ни при одном последующем
+рестарте; правки нужно вносить через API. Список IP-адресов
+(`ip_addresses` в YAML) — исключение, он остаётся отдельным, всегда
+аддитивным путём постановки в очередь при каждом старте (см.
+[SETUP.md](SETUP.md#развёртывание-control-api)); он не конфликтует с
+`POST /api/v1/admin/ips` ниже.
+
+### `POST /api/v1/admin/ips`
+
+Единая точка для двух задач: добавить новые адреса в очередь **и**
+принудительно перепроверить уже завершённые — один и тот же вызов, разница
+только в текущем состоянии каждого конкретного адреса. Список
+обрабатывается в one transaction, в порядке следования адресов:
+
+- адрес неизвестен control-api → добавляется в очередь как новый
+ (`queued`);
+- адрес сейчас `done`/`failed` → принудительно перезапускается: сбрасывается
+ результат, `attempt_number` увеличивается, `retry_count` обнуляется,
+ адрес снова становится `queued`;
+- адрес сейчас `queued` (ещё не взят в работу) → только переупорядочивается
+ под порядок текущего списка, повторно не добавляется;
+- адрес сейчас активно проверяется (`assigning_fip` / `awaiting_self_check`
+ / `checking` / `aggregating`) → не трогается вообще — нельзя запустить
+ вторую параллельную проверку одного и того же адреса.
+
+Порядок обработки внутри одного вызова соответствует порядку адресов в
+списке; повторная отправка того же списка позже даёт тот же относительный
+порядок прогона.
+
+Запрос:
+```json
+{"addresses": ["203.0.113.10", "203.0.113.11"]}
+```
+
+Ответ (`200`):
+```json
+{
+ "added": ["203.0.113.11"],
+ "requeued": ["203.0.113.10"],
+ "reordered": [],
+ "skipped_in_progress": []
+}
+```
+
+`400`, если `addresses` пуст.
+
+### `POST /api/v1/admin/ips/{ip}/cancel`
+
+Принудительно останавливает проверку конкретного адреса, не дожидаясь
+`checking_window_seconds` — работает из любого нетерминального состояния,
+включая `queued` (в этом случае это просто удаление ещё не начатой
+проверки из очереди). Если Floating IP уже привязан — отвязывается
+(best-effort, как и при обычном завершении проверки); владеющий валидатор
+освобождается. Итог записывается как `overall_result: "cancelled"`
+(состояние `failed`).
+
+Ответ: `{"ok": true}`. `404`, если адрес неизвестен. `409`, если адрес уже
+в терминальном состоянии (`done`/`failed`/уже отменён) — отменять нечего.
+
+### Валидаторы: `/api/v1/admin/config/validators`
+
+| Метод | Путь | Тело | Успех | Ошибки |
+|---|---|---|---|---|
+| GET | `/api/v1/admin/config/validators` | — | `[{"validator_id","os_port_id","state"}]` | |
+| POST | `/api/v1/admin/config/validators` | `{"validator_id","os_port_id"}` | `201` | `409`, если `validator_id` уже существует |
+| PUT | `/api/v1/admin/config/validators/{id}` | `{"os_port_id"}` | `200` | `404` |
+| DELETE | `/api/v1/admin/config/validators/{id}` | — | `200` | `404`; `409`, если валидатор сейчас владеет IP |
+
+### Площадки: `/api/v1/admin/config/sites`
+
+Слотов ровно три (`index` ∈ {1, 2, 3}) — это ограничение схемы БД
+(`ip_queue.site{1,2,3}_complete`), а не искусственное. Пустой список слотов
+— штатный сценарий, отключающий inbound-проверки целиком (см.
+[USAGE.md](USAGE.md#управление-площадками-проберами)).
+
+| Метод | Путь | Тело | Успех | Ошибки |
+|---|---|---|---|---|
+| GET | `/api/v1/admin/config/sites` | — | `[{"index","site_id"}]` (до 3 строк) | |
+| PUT | `/api/v1/admin/config/sites/{index}` | `{"site_id"}` | `200` | `400`, если `index` не 1..3; `409`, если `site_id` уже занят другим слотом |
+| DELETE | `/api/v1/admin/config/sites/{index}` | — | `200` | `404` |
+
+### Группы целей: `/api/v1/admin/config/targets`
+
+Группа целей — именованный список URL/адресов (например `stub-targets: [
+"https://hub.docker.com", ...]`), на который затем ссылаются типы
+проверок.
+
+| Метод | Путь | Тело | Успех | Ошибки |
+|---|---|---|---|---|
+| GET | `/api/v1/admin/config/targets` | — | `[{"name","targets"}]` | |
+| PUT | `/api/v1/admin/config/targets/{group}` | `{"targets":[...]}` | `200` | `400`, если список пуст |
+| DELETE | `/api/v1/admin/config/targets/{group}` | — | `200` | `404`; `409`, если группа используется каким-то `check_type` |
+
+### Типы проверок: `/api/v1/admin/config/check-types`
+
+Тип проверки (`https`, `icmp`, `ssh`, ...) ссылается на одну или несколько
+групп целей по имени; именно развёрнутый список отсюда validator-agent
+получает в `check_config` при `GET /api/v1/agents/{id}/assignment`.
+
+| Метод | Путь | Тело | Успех | Ошибки |
+|---|---|---|---|---|
+| GET | `/api/v1/admin/config/check-types` | — | `[{"name","enabled","targets"}]` (`targets` — имена групп) | |
+| PUT | `/api/v1/admin/config/check-types/{name}` | `{"enabled","targets":["group",...]}` | `200` | `400`, если названа несуществующая группа |
+| DELETE | `/api/v1/admin/config/check-types/{name}` | — | `200` | `404` |
+
+### Пример: конфигурация целиком через API, без единой строки в YAML
+
+```bash
+BASE=http://127.0.0.1:8080
+
+curl -s -X POST "$BASE/api/v1/admin/config/validators" \
+ -d '{"validator_id":"validator_01","os_port_id":"port-abc123"}'
+
+curl -s -X PUT "$BASE/api/v1/admin/config/targets/web" \
+ -d '{"targets":["https://hub.docker.com","https://github.com"]}'
+
+curl -s -X PUT "$BASE/api/v1/admin/config/check-types/https" \
+ -d '{"enabled":true,"targets":["web"]}'
+
+curl -s -X PUT "$BASE/api/v1/admin/config/sites/1" -d '{"site_id":"site-1"}'
+
+# Поставить адрес в очередь и, отдельным вызовом позже, принудительно
+# перепроверить его ещё раз — тот же метод, разница только в состоянии:
+curl -s -X POST "$BASE/api/v1/admin/ips" -d '{"addresses":["203.0.113.10"]}'
+curl -s -X POST "$BASE/api/v1/admin/ips" -d '{"addresses":["203.0.113.10"]}' # forced recheck
+
+# Остановить проверку, не дожидаясь checking_window_seconds:
+curl -s -X POST "$BASE/api/v1/admin/ips/203.0.113.10/cancel"
+```
+
## Модель состояний и связь методов с ней
```
@@ -327,9 +483,18 @@ queued ──(control-api сам, без вызова API)──▶ assigning_fi
нет — это фоновый цикл (`Tick`), а не запрос/ответ.
Площадки (`siteN_complete`) — опциональны: сколько их учитывается,
-целиком определяется списком `sites` в конфиге control-api (0–3 записи).
-Пустой список — агрегация ждёт только `egress_complete`, ни одна площадка
-не требуется. Подробнее — [USAGE.md](USAGE.md#управление-площадками-проберами).
+целиком определяется текущим списком `sites` (0–3 записи, управляется
+через `/api/v1/admin/config/sites` — см.
+[выше](#управление-очередью-и-конфигурацией)). Пустой список — агрегация
+ждёт только `egress_complete`, ни одна площадка не требуется. Подробнее —
+[USAGE.md](USAGE.md#управление-площадками-проберами).
+
+Два дополнительных перехода, оба инициируются оператором через
+`/api/v1/admin/ips`, а не самим оркестратором:
+- **любое нетерминальное состояние → `failed` (`overall_result:
+ "cancelled"`)** — `POST /api/v1/admin/ips/{ip}/cancel`;
+- **`done`/`failed` → `queued` (новая попытка)** — `POST
+ /api/v1/admin/ips` с уже завершённым адресом в списке.
## Сквозной пример работы (curl)
diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md
new file mode 100644
index 0000000..1ebf27e
--- /dev/null
+++ b/docs/DASHBOARD.md
@@ -0,0 +1,99 @@
+# Admin Dashboard
+
+`admin-dashboard` — 4-й компонент системы: браузерная веб-панель,
+дающая полное покрытие административного API `control-api`
+([docs/API.md](API.md#управление-очередью-и-конфигурацией)) без единого
+`curl`. Отдельный, полностью самостоятельный процесс — не хранит
+состояния, не подключается к базе данных напрямую, общается с
+`control-api` только через его же HTTP admin API.
+
+## Устройство
+
+- Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org)
+ (частичные обновления без перезагрузки страницы) +
+ [Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность) +
+ [Pico CSS](https://picocss.com) в classless-сборке (минималистичный вид
+ на голой семантической разметке). Никакой сборки фронтенда нет — все три
+ библиотеки вендорены как статические файлы
+ (`internal/dashboard/static/vendor/`, см. `VENDOR.md` там же) и встроены
+ в бинарник через `//go:embed`. Дашборд работает полностью офлайн — при
+ открытии страницы браузер не делает ни одного запроса за пределы самого
+ дашборда (можно проверить через DevTools → Network).
+- Браузер никогда не видит JSON `control-api` напрямую: каждая страница и
+ каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда
+ после вызова `control-api`. CORS и reverse-proxy не нужны.
+- Как и `control-api`, дашборд **не аутентифицирован** — ограничивайте
+ доступ на уровне сети/файрвола (см.
+ [SETUP.md](SETUP.md#сетевые-доступы)).
+
+## Запуск
+
+```bash
+cp configs/admin-dashboard.example.yaml /etc/cloud-ip-validator/admin-dashboard.yaml
+# отредактируйте control_api.base_url под ваш стенд
+admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
+```
+
+Развёртывание как systemd-юнита — по образцу остальных компонентов, см.
+[SETUP.md](SETUP.md#развёртывание-admin-dashboard). Порт по умолчанию —
+`:8090` (у `control-api` — `:8080`).
+
+## Страницы и что на них можно делать
+
+| Страница | Назначение |
+|---|---|
+| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. |
+| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний). |
+| `/ips/{ip}` | Детали одного адреса: все проверки текущей попытки и вся история событий. |
+| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
+| `/sites` | Три фиксированных слота площадок (1/2/3) — назначить/сменить/освободить `site_id`. |
+| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
+| `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. |
+
+### «Текущая» и «последняя завершённая» проверка
+
+В `control-api` нет понятия «запуска»/«цикла проверки» как отдельной
+сущности — есть только общая очередь IP-адресов
+(`docs/PLAN_ADMIN_DASHBOARD.md`). Дашборд ничего не меняет в этом
+устройстве и не заводит своего состояния:
+
+- **Текущая проверка** — все адреса, которые прямо сейчас не в
+ состоянии `done`/`failed` (`queued`, `assigning_fip`,
+ `awaiting_self_check`, `checking`, `aggregating`), вычисляется заново на
+ каждый запрос из `GET /api/v1/admin/status` + `GET /api/v1/admin/ips`.
+- **Последняя завершённая проверка** — последние N адресов, перешедших в
+ `done`/`failed`, отсортированные по `AggregatedAt` по убыванию (не
+ «последний запуск», а именно скользящее окно последних по времени
+ завершений).
+
+### Добавление адресов и принудительный повтор — один и тот же вызов
+
+Форма на `/ips` всегда бьёт в `POST /api/v1/admin/ips`. Поведение зависит
+от текущего состояния каждого конкретного адреса (см.
+[docs/API.md](API.md#post-apiv1adminips)): новый — встаёт в очередь;
+уже `done`/`failed` — принудительно перезапускается; уже `queued` —
+просто переупорядочивается; уже активно проверяется — не трогается
+(дашборд честно показывает это в таблице, а не делает вид, что запрос
+ничего не значил).
+
+## Конфигурация
+
+См. `configs/admin-dashboard.example.yaml`. Ключевые поля:
+
+- `server.listen_addr` — где слушает сам дашборд (по умолчанию `:8090`).
+- `control_api.base_url` — адрес `control-api`, обязателен.
+- `control_api.timeout_seconds` — таймаут HTTP-запросов к `control-api`.
+- `overview.last_completed_count` — размер окна «последних завершённых»
+ на странице обзора.
+- `overview.poll_interval_seconds` — как часто браузер опрашивает
+ `/overview/fragment` для live-обновления.
+
+## Отображение ошибок
+
+Любая ошибка `control-api` (4xx/5xx с телом `{"error":"..."}`) или сбой
+связи с ним (недоступен, таймаут) показывается баннером наверху страницы,
+а не приводит к падению дашборда — таблица/страница при этом всегда
+отражает актуальное состояние `control-api` (дашборд перезапрашивает
+данные после любой попытки мутации, независимо от её исхода). Жёлтый
+баннер — бизнес-ошибка (4xx, например «валидатор занят»), красный —
+инфраструктурная проблема (5xx или `control-api` недоступен).
diff --git a/docs/DIAGRAMS.md b/docs/DIAGRAMS.md
index 157b5c3..c699dca 100644
--- a/docs/DIAGRAMS.md
+++ b/docs/DIAGRAMS.md
@@ -32,15 +32,15 @@ control-api, фоновый оркестратор, база данных, вы
```mermaid
flowchart TB
subgraph OP["Оператор"]
- CFG["control-api.yaml (validators, sites, targets, ip_addresses, check_types)"]
+ CFG["control-api.yaml (bootstrap пустой БД: validators, sites, targets, check_types, ip_addresses)"]
ENV["control-api.env (OS_AUTH_URL, OS_TOKEN, ...)"]
- ADMIN["curl /api/v1/admin/*"]
+ ADMIN["curl /api/v1/admin/* (status/ips/validators, ips submit/cancel, config CRUD)"]
end
subgraph CAPI["control-api (управляющая машина, 1 экземпляр)"]
HTTP["HTTP API /api/v1/agents/* /api/v1/probers/* /api/v1/admin/* /healthz"]
ORCH["Оркестратор: Tick раз в poll_interval_seconds claim → associate FIP → ожидание self-check → checking → aggregate → release + lease sweep + heartbeat sweep"]
- DB[("SQLite validators / ip_queue checks / events")]
+ DB[("SQLite validators / ip_queue / sites / target_groups / check_types / checks / events")]
OSCLIENT["OpenStack-клиент (mode: mock | real)"]
end
@@ -49,9 +49,10 @@ flowchart TB
VA["validator-agent ×N (на каждой ВМ-валидаторе)"]
PR["prober ×3 (на каждой внешней площадке)"]
- CFG -->|"читается при старте (инициализация validators, ip_queue)"| CAPI
+ CFG -->|"читается только один раз, на пустых таблицах (bootstrap)"| DB
ENV -->|"переменные окружения процесса"| OSCLIENT
ADMIN --> HTTP
+ HTTP -->|"config/queue CRUD: источник истины после первого изменения"| DB
HTTP --> ORCH
ORCH <--> DB
ORCH --> OSCLIENT
@@ -63,14 +64,20 @@ flowchart TB
**Пояснение.** `control-api` — единственный компонент с состоянием и
единственная точка принятия решений (какой IP кому назначить, когда
-считать проверку завершённой). Конфигурация читается один раз при
-старте процесса (горячей перезагрузки нет — изменения требуют
-`systemctl restart control-api`, см. [SETUP.md](SETUP.md)). Оркестратор
+считать проверку завершённой). `control-api.yaml` используется только как
+одноразовый bootstrap для четырёх секций (`validators`, `sites`,
+`targets`, `check_types`) — читается лишь пока соответствующая таблица в
+БД пуста; `ip_addresses` — отдельный, всегда аддитивный путь постановки в
+очередь при каждом старте. После bootstrap все изменения этих сущностей,
+включая состав очереди и принудительные повтор/остановку проверки, идут
+через `/api/v1/admin/*` — «на лету», без `systemctl restart control-api`
+(см. [API.md](API.md#управление-очередью-и-конфигурацией)). Оркестратор
работает по таймеру независимо от HTTP-запросов — назначение IP
валидаторам и агрегация результатов не привязаны к конкретному входящему
-запросу, а выполняются фоновым циклом `Tick`. `validator-agent` и
-`prober` — активная сторона: они сами инициируют все HTTP-запросы к
-control-api (pull-модель), сам control-api к ним не обращается.
+запросу, читая актуальную конфигурацию из БД на каждом проходе, а не
+единожды при старте. `validator-agent` и `prober` — активная сторона: они
+сами инициируют все HTTP-запросы к control-api (pull-модель), сам
+control-api к ним не обращается.
---
diff --git a/docs/PLAN_ADMIN_DASHBOARD.md b/docs/PLAN_ADMIN_DASHBOARD.md
new file mode 100644
index 0000000..7c398d7
--- /dev/null
+++ b/docs/PLAN_ADMIN_DASHBOARD.md
@@ -0,0 +1,154 @@
+# План: `cmd/admin-dashboard` — веб-панель администратора
+
+> Статус: **реализовано**. Актуальное описание — [docs/DASHBOARD.md](DASHBOARD.md).
+
+## Context
+
+Единственный способ управлять `control-api` (очередь IP, валидаторы,
+площадки, цели проверки) — HTTP API через `curl` (см. `docs/API.md`). API
+уже покрывает весь необходимый функционал (`POST /api/v1/admin/ips` для
+постановки/принудительного повтора, `POST /api/v1/admin/ips/{ip}/cancel`
+для остановки, `/api/v1/admin/config/{validators,sites,targets,check-types}`
+для CRUD), но curl неудобен для повседневного оперирования и не даёт
+наглядной картины состояния очереди. Нужен браузерный admin dashboard —
+графический доступ ко всей этой функциональности: сводная статистика
+(текущая проверка / итог последней завершённой), управление конфигурацией
+и принудительные операции над очередью — без YAML и без перезапуска
+`control-api`.
+
+**Согласованные решения:**
+- **Без сборки фронтенда.** Server-rendered `html/template` + htmx
+ (частичные AJAX-обновления) + Alpine.js (точечная клиентская
+ интерактивность) + Pico.css classless (минимализм на голой семантической
+ разметке). Все три библиотеки вендорятся как статические файлы и
+ встраиваются через `//go:embed` — без CDN во время выполнения (принцип
+ проекта — полностью автономный бинарник, работает офлайн).
+- **Никакого нового backend-состояния.** «Текущая проверка» — live-снимок
+ IP не в терминальном состоянии. «Последняя завершённая» — последние N
+ (по умолчанию 20, настраивается) по `AggregatedAt` desc среди
+ `done`/`failed`, с разбивкой по `OverallResult`. Оба вычисляются на
+ каждый запрос из `GET /admin/status` + `GET /admin/ips` — никакого
+ понятия «запуска»/«батча» в `control-api` не добавляется.
+- **Без аутентификации** — как и сам API; доступ ограничивается сетью/firewall.
+- **Отдельный 4-й бинарник** (`cmd/admin-dashboard`), не новые маршруты
+ внутри `control-api`. Ходит в `control-api` только через существующий
+ HTTP admin API (`internal/apiclient.Client`). Рендеринг на сервере —
+ браузер не видит JSON `control-api` напрямую, CORS/reverse-proxy не нужны.
+- Малые допущения: без пагинации очереди (текущий масштаб — десятки
+ адресов); баннер ошибок различает 4xx (жёлтый) и 5xx/транспортные
+ (красный); порт дашборда по умолчанию `:8090`.
+
+## 1. Структура файлов
+
+```
+cmd/admin-dashboard/main.go
+
+internal/dashboard/
+ server.go, routes.go, client.go, dto.go, render.go, embed.go
+ handlers_overview.go, handlers_ips.go, handlers_validators.go,
+ handlers_sites.go, handlers_targets.go, handlers_checktypes.go
+ templates/ (layout, overview[+fragment], ips[+table], ip_detail,
+ validators[+table+row], sites[+table], targets[+table+row],
+ checktypes[+table+row], error_banner)
+ static/vendor/{htmx.min.js,alpine.min.js,pico.classless.min.css,VENDOR.md}
+ static/dashboard.css
+
+configs/admin-dashboard.example.yaml
+deploy/systemd/admin-dashboard.service
+docs/DASHBOARD.md
+```
+
+Таблица `ips` перерисовывается целиком при любой мутации (`POST
+/admin/ips` может завести новые строки и поменять `sequence`).
+`validators`/`sites`/`targets`/`check-types` — точечный swap одной `
`.
+
+## 2. Маршруты дашборда
+
+| Метод | Путь | Вызов к control-api |
+|---|---|---|
+| GET | `/` | редирект на `/overview` |
+| GET | `/overview`, `/overview/fragment` | `GET /admin/status`, `GET /admin/ips` |
+| GET | `/ips`, `/ips/{ip}` | `GET /admin/ips`, `GET /admin/ips/{ip}` |
+| POST | `/ips` | `POST /admin/ips` |
+| POST | `/ips/{ip}/recheck` | `POST /admin/ips` `{"addresses":[ip]}` |
+| POST | `/ips/{ip}/cancel` | `POST /admin/ips/{ip}/cancel` |
+| GET/POST `/validators`, PUT/DELETE `/validators/{id}` | `.../config/validators[/{id}]` |
+| GET `/sites`, PUT/DELETE `/sites/{index}` | `.../config/sites[/{index}]` |
+| GET/POST `/targets`, PUT/DELETE `/targets/{group}` | `.../config/targets[/{group}]` |
+| GET/POST `/check-types`, PUT/DELETE `/check-types/{name}` | `.../config/check-types[/{name}]` |
+| GET | `/static/*` | embed.FS |
+
+`overview/fragment` — `hx-trigger="every Ns"` (из конфига), без фонового
+тикера на сервере. `POST /targets`/`/check-types` — перевод «форма с
+именем» → `PUT .../{name}` (control-api там upsert).
+
+## 3. Ошибки control-api
+
+`client.go`: не-2xx → `*apiErr{Status, Message}`. Хендлеры не отдают 500 —
+рендерят страницу/фрагмент + out-of-band `error_banner.html`
+(`hx-swap-oob="true"`, `id="error-banner"` в `layout.html`); статус ответа
+дашборда = статус control-api (502 при транспортной ошибке). Баннер жёлтый
+для 4xx, красный для 5xx/транспортных.
+
+## 4. Конфигурация
+
+`internal/config/config.go`, секция `AdminDashboard{Server, ControlAPI{
+BaseURL, TimeoutSeconds}, Overview{LastCompletedCount, PollIntervalSeconds}}`
++ `LoadAdminDashboard` (defaults: `:8090`, timeout 10s, N=20, poll=5s;
+`BaseURL` обязателен). `configs/admin-dashboard.example.yaml`,
+`deploy/systemd/admin-dashboard.service` (без CAP_NET_RAW/EnvironmentFile).
+
+## 5. DTO и клиент
+
+`internal/dashboard/dto.go` — свои wire-структуры (не импортируют
+приватные DTO `internal/httpapi`, тот же паттерн, что `internal/probercore`):
+snake_case-структуры дословно повторяют `internal/httpapi/dto_admin.go`;
+для `GET /admin/ips[/{ip}]`/`GET /admin/validators` — зеркала untagged
+PascalCase `db.IPQueueItem`/`db.Validator`/`db.Check`/`db.Event`.
+
+`client.go` — обёртка над `apiclient.Client`: `Status`, `ListIPs`, `GetIP`,
+`SubmitIPs`, `CancelIP`, CRUD-методы для validators/sites/target-groups/
+check-types.
+
+`handlers_overview.go` — чистые функции: `currentlyChecking`,
+`lastCompleted(items, n)`, `resultBreakdown`.
+
+## 6. Вендоринг статики
+
+Скачать один раз, закоммитить, задокументировать в `VENDOR.md`:
+`htmx.min.js` (unpkg htmx.org, ядро без расширений), `alpine.min.js`
+(unpkg alpinejs `dist/cdn.min.js`, IIFE-сборка), `pico.classless.min.css`
+(unpkg @picocss/pico).
+
+## 7. Тестирование
+
+`httptest`-фейковый control-api + `httptest`-дашборд поверх него, проверка
+рендера через `strings.Contains` (по образцу `internal/httpapi/handlers_config_test.go`).
+Кейсы: overview live-агрегация и `resultBreakdown`; submit (happy +
+пустой список); recheck (done→requeued, checking→skipped, явно показано);
+cancel (happy + 409); CRUD-раунд-трип + конфликты (409/400) по всем 4
+сущностям; control-api недоступен → баннер + 502.
+
+**Обязательный ручной шаг:** браузерный смоук-тест против
+`scripts/run-local-e2e.sh` (или отдельного mock control-api) — все
+страницы/формы/действия, live-обновление во время реального прогона,
+проверка через DevTools Network отсутствия внешних (CDN) запросов.
+
+## 8. Документация
+
+Новый `docs/DASHBOARD.md`; `README.md` («три компонента» →
+«четыре»); `docs/SETUP.md` (компонент + раздел развёртывания + сетевые
+доступы); `docs/API.md` (отсылка на дашборд в предупреждении об
+открытости API).
+
+## Критичные файлы
+
+`internal/dashboard/{client.go,dto.go,routes.go,server.go,
+handlers_overview.go,render.go,embed.go}`, `internal/config/config.go`
+(`AdminDashboard`), `cmd/admin-dashboard/main.go`.
+
+## Проверка
+
+1. `go build ./... && go test ./...`
+2. `scripts/run-local-e2e.sh` + `admin-dashboard` отдельно против того же control-api
+3. Ручной браузерный смоук-тест (обязателен, не пропускается)
diff --git a/docs/PLAN_API_CONFIG_MANAGEMENT.md b/docs/PLAN_API_CONFIG_MANAGEMENT.md
index df05092..156c4cb 100644
--- a/docs/PLAN_API_CONFIG_MANAGEMENT.md
+++ b/docs/PLAN_API_CONFIG_MANAGEMENT.md
@@ -1,53 +1,70 @@
-# План доработки: API управления конфигурацией
+# План доработки: динамическое управление конфигурацией и очередью через API
-> Статус: **план на будущее, не реализовано**. Документ фиксирует
-> согласованный дизайн доработки Control API, дающей возможность
-> управлять `check_types`, `targets`, `validators` и `sites` через HTTP
-> API вместо правки YAML + рестарта. Реализация — отдельная задача.
+> Статус: **реализовано**. Документ фиксирует дизайн доработки Control
+> API, дающей возможность управлять `validators`, `sites`,
+> `check_types`/`targets` и очередью IP-адресов через HTTP API вместо
+> правки YAML + рестарта, без перезапуска процесса. Актуальная
+> спецификация методов — [docs/API.md](API.md#управление-очередью-и-конфигурацией);
+> повседневные сценарии — [docs/USAGE.md](USAGE.md).
## Context
-Сейчас `control-api` полностью read-only в части конфигурации: типы
-проверок (`check_types`), список целей (`targets`), состав валидаторов
-(`validators`) и внешних площадок (`sites`) читаются один раз из
-`control-api.yaml` при старте процесса и живут дальше только в памяти
-(`Orchestrator.Checks`, `Orchestrator.Sites`) либо (для валидаторов) в
-таблице `validators`, куда при каждом рестарте они переупорядочиваются из
-YAML. Единственный способ что-то поменять — отредактировать YAML и
-выполнить `systemctl restart control-api`. Это задокументированное
-ограничение (см. `docs/USAGE.md`).
+Сейчас `control-api` в части конфигурации и очереди полностью read-only:
+`validators`, `sites`, `check_types`/`targets` читаются один раз из YAML при
+старте процесса (`cmd/control-api/main.go`) и живут в памяти
+(`Orchestrator.Checks`, `Orchestrator.Sites`) либо переприменяются в БД при
+каждом рестарте (`RegisterValidator` upsert). Список адресов на проверку
+(`ip_addresses`) добавляется в очередь только при старте, и нет способа
+принудительно перепроверить уже завершённый адрес или остановить проверку,
+которая уже идёт. Единственный способ что-то поменять — отредактировать
+YAML и выполнить `systemctl restart control-api`.
-Согласованные решения (зафиксированы для будущей реализации):
-- **Область доработки** — ровно четыре сущности: `check_types` (типы
- проверок + их привязка к группам целей), `targets` (группы целей),
- `validators` (состав ВМ-валидаторов), `sites` (состав из ≤3 внешних
- площадок). Очередь `ip_addresses` и тайминги оркестратора
- (`orchestrator.*`, `aggregation.*`, `inbound_checks.*`) — вне scope,
- остаются YAML-only как сейчас.
-- **Источник истины после первого изменения — БД.** YAML используется
- только для одноразового bootstrap при пустой базе; после первого
- запуска (или после первого API-изменения) YAML для этих 4 секций
- больше не перечитывается и не переприменяется при рестартах.
-- **Добавляется базовая аутентификация** — bearer-токен администратора,
- которым закрывается весь namespace `/api/v1/admin/*` (не только новые
- write-методы, но и существующие read-методы `status/ips/validators` —
- единая политика для всего admin-namespace проще и логичнее половинчатой
- защиты). Протокол `/api/v1/agents/*` и `/api/v1/probers/*`
- (agent/prober) — вне scope, остаётся как есть.
+Целевой набор фич:
-## Важное архитектурное ограничение: площадки жёстко капнуты на 3
+1. Администратор передаёт через API список IP-адресов на проверку.
+2. Администратор передаёт через API список валидаторов.
+3. Администратор передаёт через API список целей (`targets`/`check_types`).
+4. Администратор может принудительно инициировать проверку адреса, даже
+ если она уже была выполнена ранее.
+5. Администратор может принудительно остановить идущую проверку.
-Схема `ip_queue` хранит завершённость площадок как три отдельные колонки
-(`site1_complete`, `site2_complete`, `site3_complete`) — это не список
-произвольной длины. Поэтому API для `sites` не может быть обычным
-CRUD-списком: это управление максимум тремя пронумерованными слотами
-(`index` ∈ {1,2,3}), где `site_id` можно назначить, переименовать или
-снять со слота. Это ограничение уже описано в `docs/USAGE.md` и явно
-закладывается в дизайн API ниже, а не игнорируется.
+Все действия — «на лету», без перезапуска процесса. Источник истины после
+первого изменения через API — БД, YAML остаётся только bootstrap для пустой
+базы.
-## Общий план реализации
+**Согласованные решения:**
+- **Sites (площадки) включены в объём доработки** — тем же CRUD-подходом,
+ что и validators/targets/check_types.
+- **Аутентификация (admin bearer-токен) НЕ входит в этот план** — API
+ остаётся открытым, как сейчас. Ограничение доступа — на уровне
+ сети/firewall (см. `docs/SETUP.md`).
+- **Фичи 1 и 4 реализуются одним механизмом**, а не двумя разными
+ эндпоинтами. Администратор передаёт список IP-адресов в
+ `POST /api/v1/admin/ips`; для каждого адреса в списке:
+ - если адрес не встречался раньше — добавляется в очередь как новый;
+ - если адрес уже в терминальном состоянии (`done`/`failed`) —
+ принудительно перезапускается на проверку (сброс результата, новая
+ попытка, `attempt_number` увеличивается, `retry_count` обнуляется);
+ - если адрес уже в очереди (`queued`) — переупорядочивается под порядок
+ текущего списка (без дублирования);
+ - если адрес сейчас активно проверяется (`assigning_fip` /
+ `awaiting_self_check` / `checking` / `aggregating`) — не трогается
+ вообще (не создаём вторую параллельную проверку одного и того же
+ адреса).
-### 1. Новая схема БД — `internal/db/migrations/0002_dynamic_config.sql`
+ Порядок обработки в рамках одного вызова соответствует порядку адресов в
+ переданном списке — повторная отправка того же списка без изменений даёт
+ тот же порядок прогона.
+
+## 1. Схема БД — новая миграция + обобщение `migrate()`
+
+`internal/db/db.go: migrate()` сейчас гейтится по `PRAGMA user_version`:
+`>=1 → no-op`, иначе применяет единственный embedded `migrations/0001_init.sql`
+и ставит `user_version=1`. Обобщается на упорядоченный список миграций
+(embed `0002_dynamic_config.sql` вторым файлом), применяются по очереди все
+версии выше текущей.
+
+Новый файл `internal/db/migrations/0002_dynamic_config.sql`:
```sql
CREATE TABLE sites (
@@ -73,229 +90,98 @@ CREATE TABLE check_types (
);
```
-Список целей внутри группы и список групп внутри типа проверки хранятся
-как JSON-массив в TEXT-колонке (тот же паттерн, что уже используется для
-`events.payload`) — они всегда читаются/пишутся целиком, отдельная
-реляционная таблица тут не нужна (не переусложняем).
+`validators` — существующая таблица (`0001_init.sql`), новых колонок не
+требует.
-`validators` — существующая таблица, новых колонок не требует.
+## 2. Типизированные ошибки — `internal/db/errors.go`
-`internal/db/db.go`: функцию `migrate()` обобщить со списка из одной
-миграции (`version >= 1 → return`) на упорядоченный список
-`{version, sql}` и применение всех версий выше текущего
-`PRAGMA user_version` — понадобится и для этой, и для будущих миграций.
+Сентинелы (`errors.New` + `%w`-обёртка), чтобы `httpapi`-хендлеры маппили
+их в HTTP-статусы через `errors.Is`: `ErrNotFound` (404), `ErrConflict`
+(409), `ErrBusy` (409, валидатор владеет IP), `ErrInUse` (409, группа
+целей используется check_type'ом), `ErrValidation` (400), `ErrInvalidState`
+(409, попытка отменить уже завершённую проверку).
-### 2. Bootstrap-логика — новый файл `internal/db/bootstrap.go`
+## 3. Bootstrap — `internal/db/bootstrap.go`
```go
func (d *DB) BootstrapFromConfig(ctx context.Context, cfg *config.ControlAPI) error
```
-Переносит и обобщает то, что сейчас разбросано по
-`cmd/control-api/main.go` (`RegisterValidator` в цикле + `SeedQueue`):
+Заменяет текущий цикл `RegisterValidator` + `SeedQueue` в
+`cmd/control-api/main.go`:
+- `ip_addresses` → `SeedQueue` — без изменений (всегда доливает новые
+ адреса при каждом старте; отдельный YAML-only путь, не путать с runtime
+ `POST /api/v1/admin/ips`).
+- `validators`, `sites`, `target_groups`, `check_types` — применяются
+ только если соответствующая таблица пуста. Если строки уже есть — YAML
+ для этой секции игнорируется.
-- `ip_addresses` → `SeedQueue` — **без изменений**, как сейчас (всегда
- доливает новые адреса, это уже вне scope доработки).
-- `validators`, `sites`, `target_groups`, `check_types` — **новая
- семантика**: применяется, **только если соответствующая таблица
- сейчас пуста** (`SELECT COUNT(*) ... == 0`). Если в таблице уже есть
- строки — YAML для этой секции полностью игнорируется, ничего не
- трогаем. Это и есть «bootstrap один раз, дальше БД главная».
+**Осознанное изменение поведения**: сейчас `RegisterValidator` при каждом
+рестарте переприменяет `os_port_id` из YAML поверх БД. После доработки —
+только на пустой таблице (иначе API-правки не переживали бы рестарт).
-`internal/db` уже не будет зависеть от `internal/orchestrator` — только
-новая зависимость `internal/db → internal/config` (обратной зависимости
-`config → db` нет, циклов не возникает).
+## 4. Запросы к БД
-`cmd/control-api/main.go`: заменить текущий цикл `RegisterValidator` +
-`SeedQueue` одним вызовом `database.BootstrapFromConfig(ctx, cfg)`. Это
-же делает функцию тестируемой напрямую (используется в обновлённых
-`orchestrator_test.go`/`httpapi_test.go` вместо ручного построения
-`Orchestrator.Checks`/`.Sites`).
+- `internal/db/queries_sites.go`: `ListSites`, `UpsertSite(idx, siteID)`,
+ `DeleteSite(idx)`, `GetSiteIndex(ctx, siteID) (int, error)` (0, если не
+ найден — не ошибка).
+- `internal/db/queries_targetgroups.go`: `ListTargetGroups`,
+ `UpsertTargetGroup(name, targets)`, `DeleteTargetGroup(name)`
+ (`ErrInUse`, если ссылается check_type), `GetTargetGroup(name)`.
+- `internal/db/queries_checktypes.go`: `ListCheckTypes`,
+ `ListResolvedCheckTypes` (разворачивает группы в плоский список targets),
+ `UpsertCheckType(name, enabled, targetGroups)` (`ErrValidation`, если
+ группа не существует), `DeleteCheckType(name)`.
+- `internal/db/queries_validators.go` (дополнить): `AdminCreateValidator`,
+ `AdminUpdateValidatorPort`, `DeleteValidator` (`ErrBusy`, если владеет
+ IP).
+- `internal/db/queries_ipqueue.go` (дополнить): `SubmitIPs(ctx,
+ addresses []string) (SubmitIPsResult, error)` — единая транзакция,
+ реализует правило из Context выше; `CancelIP(ctx, ipID int64) error` —
+ условный `UPDATE ... WHERE state NOT IN ('done','failed')`,
+ `ErrInvalidState` при гонке/уже завершённой проверке.
-**Важное следствие смены семантики валидаторов**: сейчас при каждом
-рестарте `control-api` валидаторы из YAML переприменяются (в частности,
-может тихо откатить `os_port_id`, изменённый через API/вручную в БД).
-После доработки — только на пустой таблице. Это осознанное поведенческое
-изменение, требует апдейта `docs/SETUP.md`/`docs/USAGE.md` (шаг 6 плана).
+`ResultCancelled = "cancelled"` добавляется в `internal/db/models.go`.
-### 3. Запросы к БД для новых сущностей
+## 5. Оркестратор
-Новые файлы, по аналогии с существующими `queries_*.go`:
+`internal/orchestrator/orchestrator.go`: убрать статические поля
+`Checks`/`Sites`, читать динамически из БД (`ListResolvedCheckTypes`,
+`ListSites`, `GetSiteIndex`) в `AssignmentForValidator`,
+`expectedCheckCount()`, `isReadyToAggregate()`. Новый метод `ForceCancel`
+для фичи 5: отвязывает FIP (best-effort), помечает IP `cancelled`,
+освобождает валидатора.
-- **`internal/db/queries_sites.go`**: `ListSites`, `UpsertSite(idx, siteID)`
- (проверяет допустимость `idx` 1..3 и уникальность `site_id` до записи,
- чтобы вернуть чистую типизированную ошибку, а не сырую SQL), `DeleteSite(idx)`,
- `GetSiteIndex(siteID) (int, error)` — заменяет текущий
- `Orchestrator.SiteIndexForID`, который сканирует статический слайс.
-- **`internal/db/queries_targetgroups.go`**: `ListTargetGroups`,
- `UpsertTargetGroup(name, targets)`, `DeleteTargetGroup(name)` —
- **перед удалением проверяет**, что ни один `check_types` не ссылается
- на эту группу (иначе `ErrInUse`), `GetTargetGroup(name)`.
-- **`internal/db/queries_checktypes.go`**: `ListCheckTypes`,
- `ListResolvedCheckTypes` (сразу разворачивает имена групп в плоский
- список URL — то, что раньше строил `orchestrator.New()` один раз при
- старте), `UpsertCheckType(name, enabled, targetGroups)` (**проверяет**,
- что все переданные `targetGroups` существуют — иначе `ErrValidation`),
- `DeleteCheckType(name)`.
-- **`internal/db/queries_validators.go`** (дополнить существующий файл):
- `AdminCreateValidator(id, osPortID)` (409/`ErrConflict`, если уже
- есть), `AdminUpdateValidatorPort(id, osPortID)` (404/`ErrNotFound`,
- если нет), `DeleteValidator(id)` (409/`ErrBusy`, если
- `current_ip_id IS NOT NULL` — валидатор сейчас владеет IP). Не путать
- с существующим `RegisterValidator` — тот остаётся as-is и продолжает
- использоваться только агентом при самостоятельной регистрации
- (`handleAgentRegister`), полей `os_port_id` не трогает при
- self-registration (это уже так в текущем коде).
+**Принятый компромисс**: если конфигурация меняется API-запросом ровно в
+момент агрегации уже идущей проверки, эта попытка агрегирует по текущей
+(уже изменённой) конфигурации — деградирует безопасно через
+`missing_counts_as_fail`, самоисправляется на следующей попытке.
-Новый файл **`internal/db/errors.go`** с типизированными сентинелами
-(`ErrNotFound`, `ErrConflict`, `ErrBusy`, `ErrValidation`, через `errors.New`
-+ `%w`-обёртку в местах возврата) — чтобы `httpapi`-хендлеры мапили их в
-404/409/400 через `errors.Is`, а не всё подряд в 500 (как сейчас местами
-получается по умолчанию).
+## 6. HTTP API
-### 4. Оркестратор — переход на динамическое чтение конфигурации
-
-`internal/orchestrator/orchestrator.go`:
-- Убрать поля `Checks []CheckConfig` и `Sites []config.SiteConfig` из
- `Orchestrator` (сейчас вычисляются один раз в `New()` и застывают на
- весь жизненный цикл процесса — это и есть корень проблемы). `Inbound`
- остаётся статическим полем как сейчас (вне scope).
-- `AssignmentForValidator` — вместо `return item, o.Checks, nil` вызывает
- `o.DB.ListResolvedCheckTypes(ctx)` и возвращает актуальный на данный
- момент список.
-- `SiteIndexForID` — удаляется, вызовы (`handleProberRegister`,
- `handleProberAssignments`, `handleProberResults`) переходят на
- `o.DB.GetSiteIndex(ctx, siteID)`.
-- `expectedCheckCount()` — читает актуальные `ListResolvedCheckTypes` и
- `ListSites` из БД на момент агрегации, а не статические поля.
-
-**Принятый компромисс (осознанно, без over-engineering):** если
-`check_types`/`targets`/`sites` меняются API-запросом ровно в момент,
-когда чей-то IP уже находится в `checking` (self-check уже пройден,
-проверки уже назначены агенту), агрегация этой конкретной попытки
-посчитает *текущую* (уже изменённую) конфигурацию, а не ту, что была на
-момент выдачи задания. На практике это узкое окно в несколько секунд
-между админ-изменением и завершением проверки; деградирует безопасно —
-через существующий механизм `missing_counts_as_fail` результат в худшем
-случае будет `partial` вместо `pass` для одной попытки, самоисправляется
-на следующей (после retry/requeue). Полный snapshot-per-attempt (доп.
-колонки в `ip_queue` с зафиксированным ожидаемым числом проверок) —
-возможное будущее усиление, не требуется для этой доработки.
-
-### 5. HTTP API
-
-Новый файл **`internal/httpapi/handlers_config.go`** и DTO в
-`dto.go`. Все — под префиксом `/api/v1/admin/config/*`, JSON в
-snake_case (в отличие от существующих `/admin/status|ips|validators`,
-которые отдают сырые Go-поля в PascalCase — для новых, «настоящих»
-management-эндпоинтов сразу делаем нормальный контракт, старые не
-трогаем, чтобы не ломать уже задокументированное поведение).
-
-| Метод | Путь | Тело | Успех | Ошибки |
-|---|---|---|---|---|
-| GET | `/api/v1/admin/config/validators` | — | `[{validator_id, os_port_id, state}]` | |
-| POST | `/api/v1/admin/config/validators` | `{validator_id, os_port_id}` | 201 | 409 если уже есть |
-| PUT | `/api/v1/admin/config/validators/{id}` | `{os_port_id}` | 200 | 404 |
-| DELETE | `/api/v1/admin/config/validators/{id}` | — | 200 | 404, 409 если владеет IP |
-| GET | `/api/v1/admin/config/sites` | — | `[{index, site_id}]` (до 3 строк) | |
-| PUT | `/api/v1/admin/config/sites/{index}` | `{site_id}` | 200 | 400 если `index` не 1..3, 409 если `site_id` занят другим слотом |
-| DELETE | `/api/v1/admin/config/sites/{index}` | — | 200 | 404 |
-| GET | `/api/v1/admin/config/targets` | — | `[{name, targets}]` | |
-| PUT | `/api/v1/admin/config/targets/{group}` | `{targets:[...]}` | 200 | 400 пустой список |
-| DELETE | `/api/v1/admin/config/targets/{group}` | — | 200 | 404, 409 если используется check_type'ом |
-| GET | `/api/v1/admin/config/check-types` | — | `[{name, enabled, targets}]` | |
-| PUT | `/api/v1/admin/config/check-types/{name}` | `{enabled, targets:[group,...]}` | 200 | 400 если группа не существует |
-| DELETE | `/api/v1/admin/config/check-types/{name}` | — | 200 | 404 |
-
-`routes.go`: все существующие и новые `/api/v1/admin/*`-маршруты
-оборачиваются `s.requireAdmin(...)`.
-
-### 6. Аутентификация
-
-- `internal/config/config.go`: в `ServerConfig` добавить
- `AdminTokenEnv string \`yaml:"admin_token_env"\`` — по аналогии с
- `openstack.*_env` полями (в YAML — только *имя* переменной, не сам
- токен).
-- `internal/httpapi/server.go`: `Server.AdminToken string` +
- `func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc`
- — сверяет `Authorization: Bearer ` через
- `crypto/subtle.ConstantTimeCompare`. Если `s.AdminToken == ""` —
- пропускает без проверки (обратная совместимость).
-- `cmd/control-api/main.go`: если `cfg.Server.AdminTokenEnv` задан, но
- `os.Getenv(...)` пуст — **отказ запуска** с понятной ошибкой
- (fail-safe, не запускаемся с «пустым паролем»). Если
- `AdminTokenEnv` вообще не задан — запускаемся как сейчас, но пишем
- явный `log.Warn` про незащищённый admin API.
-- `configs/control-api.example.yaml`, `deploy/systemd/control-api.service`
- (добавить пример переменной в `EnvironmentFile`) — обновить.
-
-### 7. Обновление существующих тестов и добавление новых
-
-- `internal/orchestrator/orchestrator_test.go`,
- `internal/httpapi/httpapi_test.go`: заменить ручное построение
- `cfg.CheckTypes/.Targets/.Sites` + прямые поля `Orchestrator{Checks:...}`
- на `db.BootstrapFromConfig(ctx, cfg)` перед `orchestrator.New(...)` —
- сами тестовые сценарии (happy path, partial, lease reclaim) не меняются
- по сути, меняется только способ засеять конфигурацию.
-- Новые unit-тесты: `internal/db/queries_dynconfig_test.go` (CRUD +
- граничные случаи: удаление занятого валидатора → `ErrBusy`, удаление
- группы целей, на которую ссылается check_type → `ErrInUse`,
- upsert check_type с несуществующей группой → `ErrValidation`, upsert
- сайта с чужим `site_id` → `ErrConflict`, bootstrap на непустой таблице
- → YAML игнорируется).
-- Новый `internal/httpapi/handlers_config_test.go` (или расширение
- `httpapi_test.go`): сквозной сценарий — создать валидатора и сайт через
- API вместо конфига, убедиться, что IP реально дошёл до `done`; смена
- `check_types` между запусками влияет на следующий назначенный IP.
-- Обновить `scripts/run-local-e2e.sh` не требуется по сути (bootstrap
- из YAML при пустой БД работает как раньше), но стоит добавить один шаг
- с `curl -X PUT .../config/check-types/ssh` как живую демонстрацию.
-
-### 8. Документация (после реализации)
-
-- `docs/API.md`: новый раздел «Методы управления конфигурацией» с
- таблицей выше + примеры curl (создание валидатора, отключение ssh,
- добавление цели, назначение площадки на слот) + раздел про
- `Authorization: Bearer`.
-- `docs/SETUP.md`: шаг про `server.admin_token_env` в
- «Переменные окружения для OpenStack» (переименовать раздел или
- добавить рядом «и для admin-токена»); явно описать новую
- bootstrap-once семантику `validators`/`sites`/`check_types`/`targets`.
-- `docs/USAGE.md`: заменить текущие разделы «Управление валидаторами» /
- «Управление площадками» (сейчас там «только через YAML + restart») на
- актуальные — через API; убрать утверждение «нет API-метода» там, где
- оно перестало быть верным.
-- `docs/DIAGRAMS.md`: в диаграмму control plane (раздел 1) добавить
- новую стрелку «Оператор → HTTP API → БД (config CRUD)» вместо текущей
- «CFG → читается при старте (инициализация)» как единственного пути.
+`POST /api/v1/admin/ips` — постановка/принудительный перезапуск (фичи 1 и
+4). `POST /api/v1/admin/ips/{ip}/cancel` — остановка (фича 5).
+`/api/v1/admin/config/{validators,sites,targets,check-types}` — CRUD
+(фичи 2 и 3), snake_case DTO. Подробности — `docs/API.md`.
## Критичные файлы
-- `internal/db/migrations/0002_dynamic_config.sql` (новый)
-- `internal/db/db.go` (обобщить `migrate()`)
-- `internal/db/bootstrap.go` (новый)
-- `internal/db/errors.go` (новый)
+- `internal/db/migrations/0002_dynamic_config.sql`, `internal/db/db.go`,
+ `internal/db/bootstrap.go`, `internal/db/errors.go`, `internal/db/models.go`
- `internal/db/queries_sites.go`, `queries_targetgroups.go`,
- `queries_checktypes.go` (новые), `queries_validators.go` (дополнить)
-- `internal/orchestrator/orchestrator.go` (убрать статические
- `Checks`/`Sites`, читать из БД)
-- `internal/httpapi/handlers_config.go` (новый), `dto.go`, `routes.go`,
- `server.go` (`requireAdmin`)
-- `internal/config/config.go` (`AdminTokenEnv`)
-- `cmd/control-api/main.go` (bootstrap-вызов, проверка токена при старте)
+ `queries_checktypes.go`, `queries_validators.go`, `queries_ipqueue.go`
+- `internal/orchestrator/orchestrator.go`
+- `internal/httpapi/handlers_config.go`, `handlers_admin.go`,
+ `dto_admin.go`, `routes.go`
+- `cmd/control-api/main.go`
-## Проверка (когда план будет реализовываться)
+## Проверка
-1. `go build ./... && go test ./...` — все существующие + новые unit- и
- httpapi-тесты проходят.
-2. `scripts/run-local-e2e.sh` — офлайн-сценарий по-прежнему проходит от
- начала до конца без ручного вмешательства (bootstrap из YAML при
- пустой БД работает как раньше).
-3. Ручная проверка нового контракта: поднять `control-api` с пустой БД и
- `admin_token_env` без токена → админ-запрос без заголовка проходит;
- задать токен → запрос без `Authorization` получает 401; создать
- валидатора/площадку/группу целей/тип проверки через API без
- единой строчки в YAML, убедиться, что IP реально проходит полный цикл
- проверки на этой конфигурации; попытаться удалить валидатора, пока он
- владеет IP → 409; перезапустить `control-api` и убедиться, что
- API-изменения пережили рестарт, а YAML их не затёр.
+1. `go build ./... && go test ./...`
+2. `scripts/run-local-e2e.sh`
+3. Ручная проверка: создать validator/site/target-group/check-type только
+ через API; `POST /admin/ips` с уже `done`-адресом → повторный полный
+ цикл; `POST /admin/ips` с адресом в `checking` → не трогается;
+ `POST /admin/ips/{ip}/cancel` во время `checking` → FIP отвязан,
+ валидатор свободен, `overall_result=cancelled`; удаление занятого
+ валидатора → 409; рестарт control-api → все API-изменения сохранились.
diff --git a/docs/SETUP.md b/docs/SETUP.md
index 1f44b1c..86ec27a 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -21,6 +21,7 @@
- [Развёртывание control-api](#развёртывание-control-api)
- [Развёртывание validator-agent на ВМ-валидаторах](#развёртывание-validator-agent-на-вм-валидаторах)
- [Развёртывание prober на внешних площадках](#развёртывание-prober-на-внешних-площадках)
+- [Развёртывание admin-dashboard](#развёртывание-admin-dashboard)
- [Проверка после запуска](#проверка-после-запуска)
- [Сетевые доступы](#сетевые-доступы)
@@ -31,10 +32,13 @@
| `control-api` | Отдельная управляющая машина/ВМ с доступом к OpenStack API | 1 (без HA) |
| `validator-agent` | Каждая ВМ-валидатор в сервисном проекте облака | по числу валидаторов |
| `prober` | По одному на каждой из внешних тестовых площадок | 3 (по числу площадок) |
+| `admin-dashboard` | Любая машина с сетевым доступом до `control-api` (опционально) | 0 или 1 |
-`control-api` — единственный компонент с состоянием (SQLite). Валидаторы и
-проберы не хранят локального состояния и полностью управляются через опрос
-control-api (см. [API.md](API.md)).
+`control-api` — единственный компонент с состоянием (SQLite). Валидаторы,
+проберы и `admin-dashboard` не хранят локального состояния и полностью
+управляются через опрос control-api (см. [API.md](API.md),
+[DASHBOARD.md](DASHBOARD.md)). `admin-dashboard` не обязателен — вся его
+функциональность доступна и через `curl` напрямую по API.
## Требования
@@ -58,7 +62,7 @@ control-api (см. [API.md](API.md)).
### Вариант A: готовые бинарники из репозитория (рекомендуется)
-В директории `bin/` репозитория уже лежат три готовых бинарника —
+В директории `bin/` репозитория уже лежат четыре готовых бинарника —
собирать их на целевых серверах не нужно, разворачивание сразу
начинается с копирования и запуска (раздел
[«Развёртывание control-api»](#развёртывание-control-api) и далее).
@@ -68,6 +72,7 @@ bin/
├── control-api # ~11 МБ
├── validator-agent # ~7 МБ
├── prober # ~7 МБ
+├── admin-dashboard # ~9 МБ (опционален, см. DASHBOARD.md)
└── SHA256SUMS
```
@@ -97,6 +102,7 @@ sha256sum -c bin/SHA256SUMS
scp bin/control-api control-api-host:/tmp/
scp bin/validator-agent validator-host-01:/tmp/
scp bin/prober probe-site-1:/tmp/
+scp bin/admin-dashboard dashboard-host:/tmp/ # опционально
```
> Если целевая платформа отличается от linux/amd64 (например, ВМ на
@@ -113,11 +119,13 @@ export CGO_ENABLED=0 GOOS=linux GOARCH=amd64 # поменяйте GOARCH дл
go build -trimpath -ldflags="-s -w" -o bin/control-api ./cmd/control-api
go build -trimpath -ldflags="-s -w" -o bin/validator-agent ./cmd/validator-agent
go build -trimpath -ldflags="-s -w" -o bin/prober ./cmd/prober
+go build -trimpath -ldflags="-s -w" -o bin/admin-dashboard ./cmd/admin-dashboard
```
Каждый бинарник самодостаточен — скопируйте нужный файл на
соответствующую машину (control-api → управляющая машина, validator-agent
-→ каждый валидатор, prober → каждая площадка).
+→ каждый валидатор, prober → каждая площадка, admin-dashboard →
+опционально, любая машина с доступом до control-api).
Убедиться, что всё собирается и юнит-тесты проходят:
@@ -130,7 +138,7 @@ go build ./... && go test ./...
обновляются автоматически**:
```bash
-sha256sum bin/control-api bin/validator-agent bin/prober | sed 's#bin/##' > bin/SHA256SUMS
+sha256sum bin/control-api bin/validator-agent bin/prober bin/admin-dashboard | sed 's#bin/##' > bin/SHA256SUMS
```
## Быстрая проверка без OpenStack (offline-режим)
@@ -181,6 +189,15 @@ cp configs/control-api.example.yaml /etc/cloud-ip-validator/control-api.yaml
целей для egress-проверок (по умолчанию — hub.docker.com, github.com,
packages.ubuntu.com) или включите `ssh` (по умолчанию выключен).
+> `validators`, `sites`, `targets` и `check_types` читаются из этого файла
+> только один раз — при самом первом старте против пустой базы данных
+> (bootstrap). После этого все последующие изменения этих четырёх секций
+> вносятся через `/api/v1/admin/config/*`, а не правкой YAML — см.
+> [API.md](API.md#управление-очередью-и-конфигурацией) и
+> [USAGE.md](USAGE.md#управление-валидаторами). `ip_addresses` — исключение,
+> он остаётся YAML + аддитивным добавлением при каждом старте (плюс
+> `POST /api/v1/admin/ips` для управления очередью без рестарта).
+
### 2. Переменные окружения для OpenStack
Учётные данные передаются **только** через переменные окружения — никогда
@@ -276,15 +293,29 @@ systemctl enable --now control-api
**Первичная инициализация базы данных происходит автоматически** — при
первом старте `control-api` создаёт файл SQLite по пути `database.path`
-из конфига (миграция схемы применяется один раз, повторные запуски —
-no-op). Отдельной команды "init db" не требуется.
+из конфига (миграции схемы применяются один раз каждая, повторные запуски
+— no-op). Отдельной команды "init db" не требуется.
При каждом старте control-api также:
-1. Регистрирует в БД всех валидаторов из `validators` конфига (если их
- там ещё нет).
-2. Добавляет в очередь все адреса из `ip_addresses`, которых там ещё нет
- (уже обработанные ранее адреса повторно не добавляются и не
- сбрасываются — см. [USAGE.md](USAGE.md#добавление-новых-ip-в-очередь)).
+1. **Bootstrap-once для `validators`/`sites`/`targets`/`check_types`.**
+ YAML применяется **только если соответствующая таблица в БД сейчас
+ пуста** — то есть только на самом первом старте против чистой базы.
+ Как только в таблице появилась хотя бы одна строка (через этот
+ bootstrap либо через `/api/v1/admin/config/*`, см.
+ [API.md](API.md#управление-очередью-и-конфигурацией)), YAML для этой
+ секции больше не перечитывается ни при одном последующем рестарте —
+ источник истины переключается на БД. Это осознанное отличие от более
+ ранних версий, где `validators` из YAML переприменялись при каждом
+ рестарте: теперь правки, сделанные через admin API (например, смена
+ `os_port_id` валидатора), переживают рестарт вместо того, чтобы
+ тихо откатываться.
+2. **Всегда аддитивно** добавляет в очередь все адреса из
+ `ip_addresses`, которых там ещё нет (уже обработанные ранее адреса
+ повторно не добавляются и не сбрасываются — см.
+ [USAGE.md](USAGE.md#добавление-новых-ip-в-очередь)). Это отдельный,
+ не завязанный на bootstrap-once путь — не путайте с
+ `POST /api/v1/admin/ips`, который умеет то же самое (и ещё
+ принудительный повтор уже проверенных адресов) без перезапуска.
Проверить, что процесс поднялся:
@@ -327,6 +358,28 @@ systemctl enable --now prober
journalctl -u prober -f
```
+## Развёртывание admin-dashboard
+
+Опционально — вся его функциональность доступна и через `curl` напрямую
+по API (см. [API.md](API.md)). На любой машине с сетевым доступом до
+`control-api`:
+
+```bash
+cp bin/admin-dashboard /usr/local/bin/admin-dashboard
+cp deploy/systemd/admin-dashboard.service /etc/systemd/system/
+mkdir -p /etc/cloud-ip-validator
+cp configs/admin-dashboard.example.yaml /etc/cloud-ip-validator/admin-dashboard.yaml
+# отредактировать control_api.base_url под ваш стенд
+
+systemctl daemon-reload
+systemctl enable --now admin-dashboard
+journalctl -u admin-dashboard -f
+```
+
+Открыть `http://:8090/` в браузере. Подробнее о
+страницах и о том, что дашборд может (и не может) — в
+[DASHBOARD.md](DASHBOARD.md).
+
## Проверка после запуска
После того как control-api, все валидаторы и все три пробера запущены:
@@ -376,7 +429,14 @@ curl -s http://:8080/api/v1/admin/validators | python3 -m json.tool
очередь — см. [USAGE.md](USAGE.md#частые-проблемы-и-что-с-ними-делать).
- `control-api` → OpenStack Keystone/Neutron API (`OS_AUTH_URL` и далее по
каталогу сервисов).
+- `admin-dashboard` → `control-api`: тот же порт (`server.listen_addr`),
+ адрес задаётся в `control_api.base_url` конфига дашборда.
+- Оператор (браузер) → `admin-dashboard`: порт из `server.listen_addr`
+ дашборда (по умолчанию 8090).
API control-api сейчас не аутентифицирован (см. предупреждение в начале
-[API.md](API.md)) — ограничивайте доступ к порту control-api на уровне
-сети/firewall теми хостами, где реально работают валидаторы и проберы.
+[API.md](API.md)) — то же самое верно и для `admin-dashboard`, который
+это API оборачивает. Ограничивайте доступ к обоим портам на уровне
+сети/firewall: к control-api — теми хостами, где реально работают
+валидаторы, проберы и сам дашборд; к дашборду — теми, кому разрешено
+администрировать стенд.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index ff94cad..6b1693c 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -17,7 +17,9 @@
- [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу)
- [Управление валидаторами](#управление-валидаторами)
- [Управление площадками (проберами)](#управление-площадками-проберами)
+- [Управление целями проверки](#управление-целями-проверки)
- [Повторная проверка адреса](#повторная-проверка-адреса)
+- [Принудительная остановка проверки](#принудительная-остановка-проверки)
- [Частые проблемы и что с ними делать](#частые-проблемы-и-что-с-ними-делать)
## Как устроена работа с системой
@@ -43,29 +45,32 @@
## Добавление новых IP в очередь
-**В текущей версии добавление адресов происходит только через конфиг
-control-api**, отдельного API-метода "добавить IP в очередь" нет.
+Основной способ — API, без перезапуска процесса:
-1. Добавьте новые адреса в список `ip_addresses` в
- `/etc/cloud-ip-validator/control-api.yaml` (в конец списка, либо в
- нужном порядке — очередь обрабатывается строго в порядке следования
- списка, `sequence`).
-2. Перезапустите control-api:
- ```bash
- systemctl restart control-api
- ```
+```bash
+curl -s -X POST http://:8080/api/v1/admin/ips \
+ -d '{"addresses": ["203.0.113.10", "203.0.113.11"]}'
+```
-Это безопасно для уже идущей работы: при старте control-api добавляет в
-очередь только **новые** адреса (те, которых там ещё нет) — уже
-обработанные ранее адреса не сбрасываются и повторно не проверяются.
-Адреса, которые были удалены из `ip_addresses`, но уже есть в базе,
-**не удаляются** из очереди/истории автоматически — если конкретный адрес
-больше не нужно проверять и его нет в очереди/в процессе, можно просто
-оставить как есть (историю он не портит).
+```json
+{"added": ["203.0.113.10", "203.0.113.11"], "requeued": [], "reordered": [], "skipped_in_progress": []}
+```
-> Совет: держите `control-api.yaml` под версионным контролем (git) —
-> список адресов на проверку тогда одновременно служит и журналом того,
-> что вообще когда-либо ставилось в очередь.
+Адреса обрабатываются в порядке, в котором перечислены в `addresses` —
+именно в этом порядке они и встанут в очередь друг за другом. Метод
+идемпотентен относительно уже идущих проверок: адрес, который сейчас
+активно проверяется, в ответе окажется в `skipped_in_progress` и не будет
+тронут (см. [«Повторная проверка адреса»](#повторная-проверка-адреса)
+ниже — тот же метод форсирует перепроверку уже завершённых адресов).
+
+Также по-прежнему можно добавить адреса через `ip_addresses` в
+`/etc/cloud-ip-validator/control-api.yaml` и перезапустить control-api —
+при каждом старте control-api доливает в очередь только новые адреса из
+этого списка (уже обработанные ранее не сбрасываются и повторно не
+проверяются). Держать `control-api.yaml` под версионным контролем (git)
+по-прежнему полезно как журнал того, что изначально ставилось в очередь
+при разворачивании стенда — но для повседневного добавления адресов проще
+и быстрее пользоваться API выше.
## Наблюдение за очередью
@@ -108,7 +113,7 @@ curl -s http://:8080/api/v1/admin/ips \
| Поле | Значение |
|---|---|
| `IPAddress` | Проверяемый адрес |
-| `Sequence` | Позиция в очереди (порядок из конфига) |
+| `Sequence` | Позиция в очереди (порядок постановки — из конфига при первом старте либо из последнего вызова `POST /api/v1/admin/ips`) |
| `State` | Текущий этап: `queued`, `assigning_fip`, `awaiting_self_check`, `checking`, `aggregating`, `done`, `failed` |
| `OwnerValidatorID` | Какой валидатор сейчас (или последним) занимался этим адресом |
| `FIPID` | Идентификатор Floating IP в OpenStack, к которому привязан адрес (пусто, если ещё/уже не привязан) |
@@ -116,7 +121,7 @@ curl -s http://:8080/api/v1/admin/ips \
| `RetryCount` | Сколько раз адрес уже переставлялся в очередь заново |
| `EgressComplete` | Валидатор закончил исходящие проверки |
| `Site1Complete` / `Site2Complete` / `Site3Complete` | Соответствующая площадка закончила входящие проверки |
-| `OverallResult` | Итог: `pass`, `partial`, `fail`, либо пусто, пока проверка не завершена |
+| `OverallResult` | Итог: `pass`, `partial`, `fail`, `cancelled` (принудительно остановлена, см. [«Принудительная остановка проверки»](#принудительная-остановка-проверки)), либо пусто, пока проверка не завершена |
| `AssignedAt` / `AggregatedAt` / `FIPReleasedAt` | Метки времени соответствующих этапов |
## Как читать итоговый результат (pass/partial/fail)
@@ -137,6 +142,10 @@ curl -s http://:8080/api/v1/admin/ips \
трафик валидатора не пошёл через назначенный FIP — и попытки
исчерпались). Смотрите `events` по этому адресу (см. ниже), чтобы
понять, на каком шаге и почему.
+- **`cancelled`** — проверку остановил оператор через `POST
+ /api/v1/admin/ips/{ip}/cancel` (`State` при этом — `failed`), а не
+ система по итогам проверок. Отличать от обычного `fail` полезно, чтобы
+ не путать «адрес не прошёл проверку» с «проверку прервали вручную».
Отсутствие ответа от источника (площадка не прислала результат до
истечения `checking_window_seconds`) засчитывается как провал — это
@@ -180,21 +189,48 @@ curl -s http://:8080/api/v1/admin/validators | python3 -m json.tool
(занят), `unreachable` (пропустил heartbeat дольше
`orchestrator.heartbeat_timeout_seconds`).
-**Добавление нового валидатора:**
+**Добавление нового валидатора (без перезапуска control-api):**
1. Поднимите новую ВМ в сервисном проекте облака, узнайте её Neutron
`port_id`.
-2. Добавьте запись в `validators` в `control-api.yaml`
- (`validator_id` + `os_port_id`) и перезапустите `control-api`.
+2. Зарегистрируйте валидатора через API:
+ ```bash
+ curl -s -X POST http://:8080/api/v1/admin/config/validators \
+ -d '{"validator_id": "validator_05", "os_port_id": "port-abc123"}'
+ ```
3. Разверните и запустите `validator-agent` на новой ВМ с тем же
`validator_id` в его конфиге (см. [SETUP.md](SETUP.md#развёртывание-validator-agent-на-вм-валидаторах)).
+Сменить `os_port_id` уже существующего валидатора (например, после
+пересоздания ВМ) — `PUT /api/v1/admin/config/validators/{id}` с телом
+`{"os_port_id": "новый-port-id"}`.
+
+Полный список зарегистрированных валидаторов — `GET
+/api/v1/admin/config/validators` (в отличие от `GET
+/api/v1/admin/validators`, отдаёт `snake_case` и без текущего IP —
+только конфигурационные поля).
+
**Вывод валидатора из эксплуатации:** остановите на нём
`validator-agent` (`systemctl stop validator-agent`). Он перестанет
получать новые задания после того, как закончит текущее (если оно было);
если он был убит посреди работы — control-api сам заберёт у него
незавершённый адрес обратно в очередь по истечении
-`orchestrator.lease_ttl_seconds`. Удалять запись из `control-api.yaml`
-не обязательно — просто выключенный агент не будет ничего забирать.
+`orchestrator.lease_ttl_seconds`. Удалять регистрацию валидатора не
+обязательно — просто выключенный агент не будет ничего забирать. Если всё
+же нужно убрать валидатора из системы совсем:
+```bash
+curl -s -X DELETE http://:8080/api/v1/admin/config/validators/validator_05
+```
+Возвращает `409`, если валидатор прямо сейчас владеет каким-то IP —
+дождитесь освобождения (или принудительно остановите его проверку, см.
+[«Принудительная остановка проверки»](#принудительная-остановка-проверки))
+перед удалением.
+
+> Правки через `validators[]` в `control-api.yaml` тоже поддерживаются,
+> но только как bootstrap пустой базы данных при самом первом старте — как
+> только в БД есть хотя бы один валидатор, YAML для этой секции
+> игнорируется при всех последующих рестартах (см.
+> [SETUP.md](SETUP.md#развёртывание-control-api)). Для стенда, который уже
+> хоть раз запускался, используйте API выше.
## Управление площадками (проберами)
@@ -207,12 +243,28 @@ curl -s http://:8080/api/v1/admin/validators | python3 -m json.tool
Явного отдельного флага "включить/выключить" нет — самого списка `sites`
достаточно.
-Чтобы добавить площадку: добавьте `site_id` + `index` (1, 2 или 3 — см.
-ограничение ниже) в `sites` конфига control-api и разверните на площадке
-`prober` с тем же `site_id`. Чтобы отключить конкретную площадку —
-уберите соответствующую запись из `sites` и перезапустите control-api;
+Чтобы добавить площадку (без перезапуска control-api) — назначьте
+`site_id` на один из трёх слотов (`index` 1, 2 или 3 — см. ограничение
+ниже) через API:
+```bash
+curl -s -X PUT http://:8080/api/v1/admin/config/sites/1 \
+ -d '{"site_id": "site-1"}'
+```
+и разверните на площадке `prober` с тем же `site_id`. Чтобы отключить
+конкретную площадку — освободите слот:
+```bash
+curl -s -X DELETE http://:8080/api/v1/admin/config/sites/1
+```
процесс `prober` на ней можно не останавливать (он просто перестанет
-получать назначения).
+получать назначения — `POST /api/v1/probers/register` для отвязанного
+`site_id` начнёт отвечать `400`). Текущее распределение слотов — `GET
+/api/v1/admin/config/sites`.
+
+> Правки через `sites[]` в `control-api.yaml` тоже поддерживаются, но
+> только как bootstrap пустой базы данных при самом первом старте — как
+> только в БД есть хотя бы одна площадка, YAML для этой секции
+> игнорируется при всех последующих рестартах. Для стенда, который уже
+> хоть раз запускался, используйте API выше.
> Важно: количество *возможных* слотов площадок жёстко зашито в схему БД
> (`Site1Complete`/`Site2Complete`/`Site3Complete`) — не более **трёх**,
@@ -221,23 +273,104 @@ curl -s http://:8080/api/v1/admin/validators | python3 -m json.tool
> поддерживаемый сценарий; *больше* трёх потребует доработки схемы
> данных, одной правкой конфига не обойтись.
+## Управление целями проверки
+
+Набор egress-целей (`targets`) и типов проверок (`check_types`,
+привязывающих тип — `https`/`icmp`/`ssh` — к одной или нескольким группам
+целей) управляется через API так же, как валидаторы и площадки —
+изменения подхватываются немедленно, следующим же назначением от
+оркестратора, без перезапуска.
+
+Посмотреть текущий набор:
+```bash
+curl -s http://:8080/api/v1/admin/config/targets | python3 -m json.tool
+curl -s http://:8080/api/v1/admin/config/check-types | python3 -m json.tool
+```
+
+Создать/заменить группу целей и включить тип проверки, ссылающийся на
+неё:
+```bash
+curl -s -X PUT http://:8080/api/v1/admin/config/targets/web \
+ -d '{"targets": ["https://hub.docker.com", "https://github.com"]}'
+
+curl -s -X PUT http://:8080/api/v1/admin/config/check-types/ssh \
+ -d '{"enabled": true, "targets": ["web"]}'
+```
+
+Отключить тип проверки, не удаляя его (значения целей сохраняются):
+```bash
+curl -s -X PUT http://:8080/api/v1/admin/config/check-types/ssh \
+ -d '{"enabled": false, "targets": ["web"]}'
+```
+
+Удалить группу целей можно только если на неё не ссылается ни один
+`check_type` (иначе — `409`); удалить сам `check_type` можно в любой
+момент (`DELETE /api/v1/admin/config/check-types/{name}`).
+
+**Важное следствие принятого компромисса**: если конфигурация меняется
+ровно в момент, когда чей-то IP уже находится в `checking` (self-check
+уже пройден, проверки уже назначены агенту), агрегация этой конкретной
+попытки посчитает уже изменённую конфигурацию, а не ту, что была на
+момент выдачи задания. На практике это узкое окно в несколько секунд;
+деградирует безопасно — через `aggregation.missing_counts_as_fail` худший
+исход для одной попытки — `partial` вместо `pass`, самоисправляется на
+следующей попытке (в том числе через принудительный повтор, см. ниже).
+
+> Правки через `targets`/`check_types` в `control-api.yaml` тоже
+> поддерживаются, но только как bootstrap пустой базы данных при самом
+> первом старте — см. примечание в разделах выше.
+
## Повторная проверка адреса
Если адрес завершился с `failed` или `partial`, а вы хотите перепроверить
-его ещё раз (например, после устранения блокировки на стороне сети):
-на данный момент нет отдельного API-метода "перезапустить проверку".
-Самый простой путь:
-1. Убедитесь, что адрес не находится в активном состоянии (`checking`
- и т.п.) — то есть уже `done`/`failed`.
-2. Временно уберите и снова добавьте адрес в список `ip_addresses`
- (либо просто пересоздайте запись в БД вручную, если это единичный
- случай и у вас есть доступ к SQLite) и перезапустите `control-api`.
+его ещё раз (например, после устранения блокировки на стороне сети) —
+отправьте его тем же методом, что используется для постановки новых
+адресов в очередь:
-Поскольку сидирование очереди идёт по уникальности `ip_address`
-(конфликт по уже существующей записи просто игнорируется), самый чистый
-способ гарантированно перепроверить конкретный адрес — обратиться к
-администратору БД (см. следующий раздел) либо дождаться штатной
-доработки API под повторные проверки.
+```bash
+curl -s -X POST http://:8080/api/v1/admin/ips \
+ -d '{"addresses": ["203.0.113.10"]}'
+```
+
+```json
+{"added": [], "requeued": ["203.0.113.10"], "reordered": [], "skipped_in_progress": []}
+```
+
+Адрес в `requeued` означает, что он был в терминальном состоянии
+(`done`/`failed`) и его перезапустили: `AttemptNumber` увеличился,
+`RetryCount` обнулён, предыдущий `OverallResult` сброшен, адрес снова
+`queued` и будет обработан на общих основаниях. Никакой особой обработки
+для уже проверенных адресов не требуется — тот же вызов безопасно
+принимает список из новых и уже проверенных адресов одновременно;
+единственное, что метод не сделает — не запустит вторую параллельную
+проверку адреса, который прямо сейчас уже проверяется (такой адрес
+вернётся в `skipped_in_progress`, см.
+[«Добавление новых IP в очередь»](#добавление-новых-ip-в-очередь)).
+
+## Принудительная остановка проверки
+
+Если проверка адреса зависла дольше ожидаемого либо просто больше не
+актуальна, не дожидайтесь истечения `checking_window_seconds` —
+остановите её сразу:
+
+```bash
+curl -s -X POST http://:8080/api/v1/admin/ips/203.0.113.10/cancel
+```
+
+Работает из любого состояния, кроме уже терминального (`done`/`failed`
+вернут `409` — отменять нечего). Если на момент отмены был привязан
+Floating IP — он отвязывается (best-effort, как и при обычном завершении
+проверки), владевший валидатор освобождается и снова становится `idle`.
+Итог записывается как `OverallResult: "cancelled"` (в `State: "failed"`),
+и виден в истории адреса наравне с обычными результатами:
+
+```bash
+curl -s http://:8080/api/v1/admin/ips/203.0.113.10 | python3 -m json.tool
+```
+
+Чтобы позже всё же проверить этот адрес — используйте
+[«Повторную проверку адреса»](#повторная-проверка-адреса) выше, она
+одинаково работает и для отменённых, и для обычно завершённых адресов.
## Частые проблемы и что с ними делать
diff --git a/internal/config/config.go b/internal/config/config.go
index 7c792a3..9962804 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -277,6 +277,55 @@ func LoadProber(path string) (*Prober, error) {
return &c, nil
}
+// ---- admin-dashboard ----
+
+// AdminDashboard is the config for the 4th binary, cmd/admin-dashboard — a
+// stateless, server-rendered web UI over control-api's /api/v1/admin/*
+// HTTP API (see docs/DASHBOARD.md). It never talks to the database.
+type AdminDashboard struct {
+ Server ServerConfig `yaml:"server"`
+ ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
+ Overview DashboardOverviewConfig `yaml:"overview"`
+}
+
+type DashboardControlAPIConfig struct {
+ BaseURL string `yaml:"base_url"`
+ TimeoutSeconds int `yaml:"timeout_seconds"`
+}
+
+// DashboardOverviewConfig configures the overview page's "текущая
+// проверка" / "последние N завершённых" summary — see
+// docs/DASHBOARD.md#текущая-и-последняя-завершённая-проверка. Both are
+// computed fresh on every request from control-api's existing
+// status/queue endpoints; there is no persisted "run"/"batch" concept.
+type DashboardOverviewConfig struct {
+ LastCompletedCount int `yaml:"last_completed_count"`
+ PollIntervalSeconds int `yaml:"poll_interval_seconds"`
+}
+
+func LoadAdminDashboard(path string) (*AdminDashboard, error) {
+ var c AdminDashboard
+ if err := loadYAML(path, &c); err != nil {
+ return nil, err
+ }
+ if c.Server.ListenAddr == "" {
+ c.Server.ListenAddr = ":8090"
+ }
+ if c.ControlAPI.BaseURL == "" {
+ return nil, fmt.Errorf("control_api.base_url is required")
+ }
+ if c.ControlAPI.TimeoutSeconds == 0 {
+ c.ControlAPI.TimeoutSeconds = 10
+ }
+ if c.Overview.LastCompletedCount == 0 {
+ c.Overview.LastCompletedCount = 20
+ }
+ if c.Overview.PollIntervalSeconds == 0 {
+ c.Overview.PollIntervalSeconds = 5
+ }
+ return &c, nil
+}
+
func loadYAML(path string, out interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
diff --git a/internal/dashboard/client.go b/internal/dashboard/client.go
new file mode 100644
index 0000000..eb8bbf8
--- /dev/null
+++ b/internal/dashboard/client.go
@@ -0,0 +1,181 @@
+package dashboard
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// apiErr is returned by every client method for a non-2xx response from
+// control-api, or for a transport-level failure (control-api unreachable/
+// timeout, Status==0). Handlers use it (via errors.As) to render an error
+// banner with the right status/severity instead of a raw 500 — see
+// writeErrorBanner in render.go.
+type apiErr struct {
+ Status int
+ Message string
+}
+
+func (e *apiErr) Error() string {
+ if e.Status == 0 {
+ return fmt.Sprintf("control-api недоступен: %s", e.Message)
+ }
+ return fmt.Sprintf("control-api: %d %s", e.Status, e.Message)
+}
+
+// client is a thin, dashboard-local JSON client for control-api's
+// /api/v1/admin/* surface. It intentionally doesn't reuse
+// internal/apiclient.Client (shared by validator-agent/prober): that
+// client's Do only returns an opaque error, with no way to recover the
+// HTTP status code — which the dashboard needs to render 400 vs 404 vs 409
+// vs 5xx differently. Duplicating ~30 lines here avoids widening
+// apiclient's contract (and its blast radius on the other two binaries)
+// for a need only this package has.
+type client struct {
+ baseURL string
+ http *http.Client
+}
+
+func newClient(baseURL string, timeout time.Duration) *client {
+ return &client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}
+}
+
+func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
+ var reader io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("marshal request: %w", err)
+ }
+ reader = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
+ if err != nil {
+ return fmt.Errorf("build request: %w", err)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return &apiErr{Status: 0, Message: err.Error()}
+ }
+ defer resp.Body.Close()
+
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ msg := string(respBody)
+ var er errorResponse
+ if json.Unmarshal(respBody, &er) == nil && er.Error != "" {
+ msg = er.Error
+ }
+ return &apiErr{Status: resp.StatusCode, Message: msg}
+ }
+ if out != nil && len(respBody) > 0 {
+ if err := json.Unmarshal(respBody, out); err != nil {
+ return fmt.Errorf("decode response from %s %s: %w", method, path, err)
+ }
+ }
+ return nil
+}
+
+func (c *client) Status(ctx context.Context) (statusResponse, error) {
+ var out statusResponse
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/status", nil, &out)
+ return out, err
+}
+
+func (c *client) ListIPs(ctx context.Context) ([]ipQueueItem, error) {
+ var out []ipQueueItem
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips", nil, &out)
+ return out, err
+}
+
+func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error) {
+ var out ipDetailResponse
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, &out)
+ return out, err
+}
+
+// SubmitIPs is the single entry point for both adding new addresses and
+// forcing a recheck of already-finished ones — see docs/API.md.
+func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
+ var out submitIPsResponse
+ err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
+ return out, err
+}
+
+func (c *client) CancelIP(ctx context.Context, ip string) error {
+ return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil)
+}
+
+func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) {
+ var out []validatorDTO
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out)
+ return out, err
+}
+
+func (c *client) CreateValidator(ctx context.Context, id, osPortID string) error {
+ body := map[string]string{"validator_id": id, "os_port_id": osPortID}
+ return c.do(ctx, http.MethodPost, "/api/v1/admin/config/validators", body, nil)
+}
+
+func (c *client) UpdateValidator(ctx context.Context, id, osPortID string) error {
+ body := map[string]string{"os_port_id": osPortID}
+ return c.do(ctx, http.MethodPut, "/api/v1/admin/config/validators/"+url.PathEscape(id), body, nil)
+}
+
+func (c *client) DeleteValidator(ctx context.Context, id string) error {
+ return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/validators/"+url.PathEscape(id), nil, nil)
+}
+
+func (c *client) ListSites(ctx context.Context) ([]siteDTO, error) {
+ var out []siteDTO
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/sites", nil, &out)
+ return out, err
+}
+
+func (c *client) PutSite(ctx context.Context, index int, siteID string) error {
+ body := map[string]string{"site_id": siteID}
+ return c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), body, nil)
+}
+
+func (c *client) DeleteSite(ctx context.Context, index int) error {
+ return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/config/sites/%d", index), nil, nil)
+}
+
+func (c *client) ListTargetGroups(ctx context.Context) ([]targetGroupDTO, error) {
+ var out []targetGroupDTO
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/targets", nil, &out)
+ return out, err
+}
+
+func (c *client) PutTargetGroup(ctx context.Context, name string, targets []string) error {
+ body := map[string][]string{"targets": targets}
+ return c.do(ctx, http.MethodPut, "/api/v1/admin/config/targets/"+url.PathEscape(name), body, nil)
+}
+
+func (c *client) DeleteTargetGroup(ctx context.Context, name string) error {
+ return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/targets/"+url.PathEscape(name), nil, nil)
+}
+
+func (c *client) ListCheckTypes(ctx context.Context) ([]checkTypeDTO, error) {
+ var out []checkTypeDTO
+ err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/check-types", nil, &out)
+ return out, err
+}
+
+func (c *client) PutCheckType(ctx context.Context, name string, enabled bool, targets []string) error {
+ body := map[string]interface{}{"enabled": enabled, "targets": targets}
+ return c.do(ctx, http.MethodPut, "/api/v1/admin/config/check-types/"+url.PathEscape(name), body, nil)
+}
+
+func (c *client) DeleteCheckType(ctx context.Context, name string) error {
+ return c.do(ctx, http.MethodDelete, "/api/v1/admin/config/check-types/"+url.PathEscape(name), nil, nil)
+}
diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go
new file mode 100644
index 0000000..4903d8a
--- /dev/null
+++ b/internal/dashboard/dashboard_test.go
@@ -0,0 +1,358 @@
+package dashboard
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// fakeControlAPI is a minimal in-memory stand-in for control-api's
+// /api/v1/admin/* surface, serving the exact JSON shapes the dashboard's
+// client.go expects (see dto.go). It's intentionally simple — enough to
+// exercise the dashboard's rendering logic and mutation flows, not a
+// re-implementation of control-api's own business rules (that's already
+// covered by internal/httpapi's own tests).
+type fakeControlAPI struct {
+ mu sync.Mutex
+ ips []ipQueueItem
+ validators []validatorDTO
+ sites map[int]string
+ groups map[string][]string
+ checkTypes map[string]checkTypeDTO
+}
+
+func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
+ t.Helper()
+ f := &fakeControlAPI{
+ sites: map[int]string{},
+ groups: map[string][]string{},
+ checkTypes: map[string]checkTypeDTO{},
+ }
+ ts := httptest.NewServer(f.handler())
+ t.Cleanup(ts.Close)
+ return f, ts.URL
+}
+
+func writeJSON(w http.ResponseWriter, status int, v interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeAPIErr(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, errorResponse{Error: msg})
+}
+
+func (f *fakeControlAPI) handler() http.Handler {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("GET /api/v1/admin/status", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ byState := map[string]int{}
+ for _, ip := range f.ips {
+ byState[ip.State]++
+ }
+ writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)})
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ writeJSON(w, http.StatusOK, f.ips)
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ addr := r.PathValue("ip")
+ for _, ip := range f.ips {
+ if ip.IPAddress == addr {
+ writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}})
+ return
+ }
+ }
+ writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
+ })
+
+ mux.HandleFunc("POST /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var req struct {
+ Addresses []string `json:"addresses"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if len(req.Addresses) == 0 {
+ writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
+ return
+ }
+ resp := submitIPsResponse{}
+ for _, addr := range req.Addresses {
+ idx := f.findIP(addr)
+ if idx < 0 {
+ now := time.Now()
+ f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
+ resp.Added = append(resp.Added, addr)
+ continue
+ }
+ switch f.ips[idx].State {
+ case "done", "failed":
+ f.ips[idx].State = "queued"
+ f.ips[idx].AttemptNumber++
+ f.ips[idx].OverallResult = ""
+ resp.Requeued = append(resp.Requeued, addr)
+ case "queued":
+ resp.Reordered = append(resp.Reordered, addr)
+ default:
+ resp.SkippedInProgress = append(resp.SkippedInProgress, addr)
+ }
+ }
+ writeJSON(w, http.StatusOK, resp)
+ })
+
+ mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ addr := r.PathValue("ip")
+ idx := f.findIP(addr)
+ if idx < 0 {
+ writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
+ return
+ }
+ if f.ips[idx].State == "done" || f.ips[idx].State == "failed" {
+ writeAPIErr(w, http.StatusConflict, "already finished")
+ return
+ }
+ f.ips[idx].State = "failed"
+ f.ips[idx].OverallResult = "cancelled"
+ now := time.Now()
+ f.ips[idx].AggregatedAt = &now
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ writeJSON(w, http.StatusOK, f.validators)
+ })
+ mux.HandleFunc("POST /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var req validatorDTO
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ for _, v := range f.validators {
+ if v.ValidatorID == req.ValidatorID {
+ writeAPIErr(w, http.StatusConflict, "already exists")
+ return
+ }
+ }
+ req.State = "idle"
+ f.validators = append(f.validators, req)
+ writeJSON(w, http.StatusCreated, req)
+ })
+ mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ id := r.PathValue("id")
+ var req struct {
+ OSPortID string `json:"os_port_id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ for i, v := range f.validators {
+ if v.ValidatorID == id {
+ f.validators[i].OSPortID = req.OSPortID
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ return
+ }
+ }
+ writeAPIErr(w, http.StatusNotFound, "unknown validator")
+ })
+ mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ id := r.PathValue("id")
+ for i, v := range f.validators {
+ if v.ValidatorID == id {
+ f.validators = append(f.validators[:i], f.validators[i+1:]...)
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ return
+ }
+ }
+ writeAPIErr(w, http.StatusNotFound, "unknown validator")
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/config/sites", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var out []siteDTO
+ for idx, id := range f.sites {
+ out = append(out, siteDTO{Index: idx, SiteID: id})
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+ mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var idx int
+ fmt.Sscanf(r.PathValue("index"), "%d", &idx)
+ var req struct {
+ SiteID string `json:"site_id"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ for existingIdx, id := range f.sites {
+ if id == req.SiteID && existingIdx != idx {
+ writeAPIErr(w, http.StatusConflict, "site_id already assigned to another slot")
+ return
+ }
+ }
+ f.sites[idx] = req.SiteID
+ writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID})
+ })
+ mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var idx int
+ fmt.Sscanf(r.PathValue("index"), "%d", &idx)
+ delete(f.sites, idx)
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/config/targets", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var out []targetGroupDTO
+ for name, targets := range f.groups {
+ out = append(out, targetGroupDTO{Name: name, Targets: targets})
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+ mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ name := r.PathValue("group")
+ var req struct {
+ Targets []string `json:"targets"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if len(req.Targets) == 0 {
+ writeAPIErr(w, http.StatusBadRequest, "targets must not be empty")
+ return
+ }
+ f.groups[name] = req.Targets
+ writeJSON(w, http.StatusOK, targetGroupDTO{Name: name, Targets: req.Targets})
+ })
+ mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ name := r.PathValue("group")
+ for _, ct := range f.checkTypes {
+ for _, g := range ct.Targets {
+ if g == name {
+ writeAPIErr(w, http.StatusConflict, "in use by check type "+ct.Name)
+ return
+ }
+ }
+ }
+ delete(f.groups, name)
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ })
+
+ mux.HandleFunc("GET /api/v1/admin/config/check-types", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var out []checkTypeDTO
+ for _, ct := range f.checkTypes {
+ out = append(out, ct)
+ }
+ writeJSON(w, http.StatusOK, out)
+ })
+ mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ name := r.PathValue("name")
+ var req struct {
+ Enabled bool `json:"enabled"`
+ Targets []string `json:"targets"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ for _, g := range req.Targets {
+ if _, ok := f.groups[g]; !ok {
+ writeAPIErr(w, http.StatusBadRequest, "unknown target group "+g)
+ return
+ }
+ }
+ ct := checkTypeDTO{Name: name, Enabled: req.Enabled, Targets: req.Targets}
+ f.checkTypes[name] = ct
+ writeJSON(w, http.StatusOK, ct)
+ })
+ mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ delete(f.checkTypes, r.PathValue("name"))
+ writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
+ })
+
+ return mux
+}
+
+func (f *fakeControlAPI) findIP(addr string) int {
+ for i, ip := range f.ips {
+ if ip.IPAddress == addr {
+ return i
+ }
+ }
+ return -1
+}
+
+func newTestServer(t *testing.T, caURL string) *httptest.Server {
+ t.Helper()
+ log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ srv, err := New(Config{
+ ControlAPIBaseURL: caURL,
+ ControlAPITimeout: 5 * time.Second,
+ LastCompletedCount: 20,
+ OverviewPollIntervalS: 5,
+ }, log)
+ if err != nil {
+ t.Fatalf("new dashboard server: %v", err)
+ }
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+func get(t *testing.T, ts *httptest.Server, path string) string {
+ t.Helper()
+ resp, err := http.Get(ts.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return string(body)
+}
+
+func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.Values) string {
+ t.Helper()
+ req, err := http.NewRequest(method, ts.URL+path, strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := ts.Client().Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", method, path, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return string(body)
+}
diff --git a/internal/dashboard/dto.go b/internal/dashboard/dto.go
new file mode 100644
index 0000000..2cb3c02
--- /dev/null
+++ b/internal/dashboard/dto.go
@@ -0,0 +1,116 @@
+package dashboard
+
+import "time"
+
+// Wire shapes for control-api's /api/v1/admin/* surface, defined locally
+// rather than importing internal/httpapi's (unexported) DTOs or
+// internal/db's models — the same "each binary owns the wire shapes it
+// needs" pattern already used by internal/probercore and internal/agentcore.
+//
+// The read-only list/detail endpoints (ipQueueItem, validator, check,
+// event below) mirror internal/db model structs field-for-field: those
+// endpoints marshal Go structs with no json tags, so encoding/json matches
+// fields by name (case-insensitively) with no tags needed here either.
+// Everything else mirrors internal/httpapi/dto_admin.go's snake_case tags.
+
+type statusResponse struct {
+ TotalIPs int `json:"total_ips"`
+ IPsByState map[string]int `json:"ips_by_state"`
+ TotalValidators int `json:"total_validators"`
+}
+
+type ipQueueItem struct {
+ ID int64
+ IPAddress string
+ Sequence int
+ State string
+ OwnerValidatorID *string
+ FIPID string
+ AttemptNumber int
+ RetryCount int
+ LeaseExpiresAt *time.Time
+ EgressComplete bool
+ Site1Complete bool
+ Site2Complete bool
+ Site3Complete bool
+ OverallResult string
+ AssignedAt *time.Time
+ AggregatedAt *time.Time
+ FIPReleasedAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type check struct {
+ ID int64
+ IPID int64
+ IPAddress string
+ AttemptNumber int
+ ValidatorID string
+ Source string
+ CheckType string
+ Target string
+ Success bool
+ LatencyMS int64
+ Detail string
+ CheckedAt time.Time
+}
+
+type event struct {
+ ID int64
+ SourceType string
+ SourceID string
+ IPID *int64
+ EventType string
+ Payload string
+ OccurredAt time.Time
+}
+
+type ipDetailResponse struct {
+ IP ipQueueItem `json:"ip"`
+ Checks []check `json:"checks"`
+ Events []event `json:"events"`
+}
+
+type validator struct {
+ ValidatorID string
+ Hostname string
+ OSPortID string
+ State string
+ CurrentIPID *int64
+ AgentVersion string
+ LastHeartbeatAt *time.Time
+}
+
+type submitIPsResponse struct {
+ Added []string `json:"added"`
+ Requeued []string `json:"requeued"`
+ Reordered []string `json:"reordered"`
+ SkippedInProgress []string `json:"skipped_in_progress"`
+}
+
+type validatorDTO struct {
+ ValidatorID string `json:"validator_id"`
+ OSPortID string `json:"os_port_id"`
+ State string `json:"state"`
+}
+
+type siteDTO struct {
+ Index int `json:"index"`
+ SiteID string `json:"site_id"`
+}
+
+type targetGroupDTO struct {
+ Name string `json:"name"`
+ Targets []string `json:"targets"`
+}
+
+type checkTypeDTO struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Targets []string `json:"targets"`
+}
+
+type errorResponse struct {
+ Error string `json:"error"`
+}
diff --git a/internal/dashboard/embed.go b/internal/dashboard/embed.go
new file mode 100644
index 0000000..7738b49
--- /dev/null
+++ b/internal/dashboard/embed.go
@@ -0,0 +1,23 @@
+package dashboard
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+//go:embed static
+var staticFS embed.FS
+
+// staticSubFS re-roots staticFS so "static/dashboard.css" is served as
+// "/dashboard.css" under the /static/ route prefix, instead of
+// "/static/static/dashboard.css".
+func staticSubFS() fs.FS {
+ sub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ panic(err) // embedded at compile time; a bad path here can't happen at runtime
+ }
+ return sub
+}
diff --git a/internal/dashboard/handlers_checktypes.go b/internal/dashboard/handlers_checktypes.go
new file mode 100644
index 0000000..4200d33
--- /dev/null
+++ b/internal/dashboard/handlers_checktypes.go
@@ -0,0 +1,77 @@
+package dashboard
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type checkTypesPageData struct {
+ PageData
+ Items []checkTypeDTO
+ Groups []string
+}
+
+func (s *Server) loadCheckTypesPage(r *http.Request) (checkTypesPageData, error) {
+ items, err := s.CA.ListCheckTypes(r.Context())
+ if err != nil {
+ return checkTypesPageData{}, err
+ }
+ groupDTOs, err := s.CA.ListTargetGroups(r.Context())
+ if err != nil {
+ return checkTypesPageData{Items: items}, err
+ }
+ groups := make([]string, len(groupDTOs))
+ for i, g := range groupDTOs {
+ groups[i] = g.Name
+ }
+ return checkTypesPageData{Items: items, Groups: groups}, nil
+}
+
+func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
+ data, err := s.loadCheckTypesPage(r)
+ data.ActiveNav = "check-types"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "checktypes_page", data)
+}
+
+func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+ items, listErr := s.CA.ListCheckTypes(r.Context())
+ if actionErr == nil {
+ actionErr = listErr
+ }
+ s.renderFragment(w, "checktypes_table", checkTypesPageData{Items: items}, actionErr)
+}
+
+func (s *Server) handleCheckTypeCreate(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ s.renderCheckTypesTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ name := r.PostFormValue("name")
+ if name == "" {
+ s.renderCheckTypesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "имя типа проверки обязательно"})
+ return
+ }
+ enabled := r.PostFormValue("enabled") != ""
+ targets := r.PostForm["targets"]
+ err := s.CA.PutCheckType(r.Context(), name, enabled, targets)
+ s.renderCheckTypesTable(w, r, err)
+}
+
+func (s *Server) handleCheckTypeUpdate(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if err := r.ParseForm(); err != nil {
+ s.renderCheckTypesTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ enabled := r.PostFormValue("enabled") == "1"
+ targets := splitList(r.PostFormValue("targets"))
+ err := s.CA.PutCheckType(r.Context(), name, enabled, targets)
+ s.renderCheckTypesTable(w, r, err)
+}
+
+func (s *Server) handleCheckTypeDelete(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ err := s.CA.DeleteCheckType(r.Context(), name)
+ s.renderCheckTypesTable(w, r, err)
+}
diff --git a/internal/dashboard/handlers_ips.go b/internal/dashboard/handlers_ips.go
new file mode 100644
index 0000000..79dad69
--- /dev/null
+++ b/internal/dashboard/handlers_ips.go
@@ -0,0 +1,71 @@
+package dashboard
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type ipsPageData struct {
+ PageData
+ Items []ipQueueItem
+}
+
+type ipDetailData struct {
+ PageData
+ Detail ipDetailResponse
+}
+
+func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
+ items, err := s.CA.ListIPs(r.Context())
+ data := ipsPageData{Items: items}
+ data.ActiveNav = "ips"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "ips_page", data)
+}
+
+func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
+ ip := r.PathValue("ip")
+ detail, err := s.CA.GetIP(r.Context(), ip)
+ data := ipDetailData{Detail: detail}
+ data.ActiveNav = "ips"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "ip_detail_page", data)
+}
+
+// renderIPsTable re-fetches the current queue and renders the ips_table
+// fragment, tagging actionErr (if any) on the shared error banner. Called
+// after every mutating /ips/* request so the table always reflects true
+// current state regardless of whether the mutation itself succeeded.
+func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+ items, listErr := s.CA.ListIPs(r.Context())
+ if actionErr == nil {
+ actionErr = listErr
+ }
+ s.renderFragment(w, "ips_table", ipsPageData{Items: items}, actionErr)
+}
+
+func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ addresses := splitList(r.PostFormValue("addresses"))
+ if len(addresses) == 0 {
+ s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"})
+ return
+ }
+ _, err := s.CA.SubmitIPs(r.Context(), addresses)
+ s.renderIPsTable(w, r, err)
+}
+
+func (s *Server) handleIPRecheck(w http.ResponseWriter, r *http.Request) {
+ ip := r.PathValue("ip")
+ _, err := s.CA.SubmitIPs(r.Context(), []string{ip})
+ s.renderIPsTable(w, r, err)
+}
+
+func (s *Server) handleIPCancel(w http.ResponseWriter, r *http.Request) {
+ ip := r.PathValue("ip")
+ err := s.CA.CancelIP(r.Context(), ip)
+ s.renderIPsTable(w, r, err)
+}
diff --git a/internal/dashboard/handlers_overview.go b/internal/dashboard/handlers_overview.go
new file mode 100644
index 0000000..d46e92d
--- /dev/null
+++ b/internal/dashboard/handlers_overview.go
@@ -0,0 +1,91 @@
+package dashboard
+
+import (
+ "net/http"
+ "sort"
+)
+
+type overviewData struct {
+ PageData
+ Status statusResponse
+ CurrentItems []ipQueueItem
+ LastCompleted []ipQueueItem
+ Breakdown map[string]int
+ LastN int
+ PollSeconds int
+}
+
+func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
+ ctx := r.Context()
+ status, err := s.CA.Status(ctx)
+ if err != nil {
+ return overviewData{}, err
+ }
+ ips, err := s.CA.ListIPs(ctx)
+ if err != nil {
+ return overviewData{}, err
+ }
+ last := lastCompleted(ips, s.Cfg.LastCompletedCount)
+ return overviewData{
+ Status: status,
+ CurrentItems: currentlyChecking(ips),
+ LastCompleted: last,
+ Breakdown: resultBreakdown(last),
+ LastN: s.Cfg.LastCompletedCount,
+ PollSeconds: s.Cfg.OverviewPollIntervalS,
+ }, nil
+}
+
+func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
+ data, err := s.loadOverview(r)
+ data.ActiveNav = "overview"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "overview_page", data)
+}
+
+func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request) {
+ data, err := s.loadOverview(r)
+ s.renderFragment(w, "overview_fragment", data, err)
+}
+
+// currentlyChecking is every IP not yet in a terminal state, ordered by
+// queue position — the "текущая проверка" live snapshot. No backend
+// concept of a "run" exists; this is computed fresh on every request.
+func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
+ var out []ipQueueItem
+ for _, ip := range ips {
+ if ip.State != "done" && ip.State != "failed" {
+ out = append(out, ip)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
+ return out
+}
+
+// lastCompleted returns the n most recently completed (done/failed) IPs by
+// AggregatedAt descending — the "последняя завершённая проверка" summary
+// window. This is an operational definition, not a real "batch": resubmit
+// n if the window size needs tuning (overview.last_completed_count).
+func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
+ var done []ipQueueItem
+ for _, ip := range ips {
+ if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
+ done = append(done, ip)
+ }
+ }
+ sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
+ if len(done) > n {
+ done = done[:n]
+ }
+ return done
+}
+
+// resultBreakdown counts OverallResult values across exactly the given
+// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
+func resultBreakdown(items []ipQueueItem) map[string]int {
+ out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
+ for _, ip := range items {
+ out[ip.OverallResult]++
+ }
+ return out
+}
diff --git a/internal/dashboard/handlers_sites.go b/internal/dashboard/handlers_sites.go
new file mode 100644
index 0000000..72dac28
--- /dev/null
+++ b/internal/dashboard/handlers_sites.go
@@ -0,0 +1,66 @@
+package dashboard
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+)
+
+type sitesPageData struct {
+ PageData
+ Items []siteDTO // always exactly 3 entries, index 1..3, SiteID=="" for an empty slot
+}
+
+// fillSlots pads control-api's response (which only lists assigned slots)
+// out to all three fixed slots, so the table always renders 3 rows.
+func fillSlots(sites []siteDTO) []siteDTO {
+ byIndex := make(map[int]string, len(sites))
+ for _, s := range sites {
+ byIndex[s.Index] = s.SiteID
+ }
+ out := make([]siteDTO, 3)
+ for i := 0; i < 3; i++ {
+ out[i] = siteDTO{Index: i + 1, SiteID: byIndex[i+1]}
+ }
+ return out
+}
+
+func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
+ items, err := s.CA.ListSites(r.Context())
+ data := sitesPageData{Items: fillSlots(items)}
+ data.ActiveNav = "sites"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "sites_page", data)
+}
+
+func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+ items, listErr := s.CA.ListSites(r.Context())
+ if actionErr == nil {
+ actionErr = listErr
+ }
+ s.renderFragment(w, "sites_table", sitesPageData{Items: fillSlots(items)}, actionErr)
+}
+
+func (s *Server) handleSitePut(w http.ResponseWriter, r *http.Request) {
+ index, convErr := strconv.Atoi(r.PathValue("index"))
+ if convErr != nil {
+ s.renderSitesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "index должен быть числом"})
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ s.renderSitesTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ err := s.CA.PutSite(r.Context(), index, r.PostFormValue("site_id"))
+ s.renderSitesTable(w, r, err)
+}
+
+func (s *Server) handleSiteDelete(w http.ResponseWriter, r *http.Request) {
+ index, convErr := strconv.Atoi(r.PathValue("index"))
+ if convErr != nil {
+ s.renderSitesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "index должен быть числом"})
+ return
+ }
+ err := s.CA.DeleteSite(r.Context(), index)
+ s.renderSitesTable(w, r, err)
+}
diff --git a/internal/dashboard/handlers_targets.go b/internal/dashboard/handlers_targets.go
new file mode 100644
index 0000000..119ef1b
--- /dev/null
+++ b/internal/dashboard/handlers_targets.go
@@ -0,0 +1,59 @@
+package dashboard
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type targetsPageData struct {
+ PageData
+ Items []targetGroupDTO
+}
+
+func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
+ items, err := s.CA.ListTargetGroups(r.Context())
+ data := targetsPageData{Items: items}
+ data.ActiveNav = "targets"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "targets_page", data)
+}
+
+func (s *Server) renderTargetsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+ items, listErr := s.CA.ListTargetGroups(r.Context())
+ if actionErr == nil {
+ actionErr = listErr
+ }
+ s.renderFragment(w, "targets_table", targetsPageData{Items: items}, actionErr)
+}
+
+func (s *Server) handleTargetCreate(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ name := r.PostFormValue("name")
+ targets := splitList(r.PostFormValue("targets"))
+ if name == "" {
+ s.renderTargetsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "имя группы обязательно"})
+ return
+ }
+ err := s.CA.PutTargetGroup(r.Context(), name, targets)
+ s.renderTargetsTable(w, r, err)
+}
+
+func (s *Server) handleTargetUpdate(w http.ResponseWriter, r *http.Request) {
+ group := r.PathValue("group")
+ if err := r.ParseForm(); err != nil {
+ s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ targets := splitList(r.PostFormValue("targets"))
+ err := s.CA.PutTargetGroup(r.Context(), group, targets)
+ s.renderTargetsTable(w, r, err)
+}
+
+func (s *Server) handleTargetDelete(w http.ResponseWriter, r *http.Request) {
+ group := r.PathValue("group")
+ err := s.CA.DeleteTargetGroup(r.Context(), group)
+ s.renderTargetsTable(w, r, err)
+}
diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go
new file mode 100644
index 0000000..8e97cdb
--- /dev/null
+++ b/internal/dashboard/handlers_test.go
@@ -0,0 +1,196 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestOverviewFragment(t *testing.T) {
+ fake, caURL := newFakeControlAPI(t)
+ now := time.Now()
+ older := now.Add(-time.Hour)
+ fake.validators = []validatorDTO{{ValidatorID: "v1", State: "idle"}}
+ fake.ips = []ipQueueItem{
+ {IPAddress: "1.1.1.1", State: "checking", Sequence: 1, AssignedAt: &now, UpdatedAt: now, CreatedAt: now},
+ {IPAddress: "2.2.2.2", State: "done", OverallResult: "pass", AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
+ {IPAddress: "3.3.3.3", State: "failed", OverallResult: "cancelled", AggregatedAt: &older, UpdatedAt: now, CreatedAt: now},
+ }
+
+ ts := newTestServer(t, caURL)
+ body := get(t, ts, "/overview/fragment")
+
+ if !strings.Contains(body, "1.1.1.1") {
+ t.Fatalf("expected in-progress ip 1.1.1.1 in current-checking section, got:\n%s", body)
+ }
+ if strings.Contains(body, `href="/ips/1.1.1.1"`) {
+ // currently-checking rows link to /ips detail too; that's fine, just
+ // make sure the done one shows up in the "last completed" table.
+ }
+ if !strings.Contains(body, "2.2.2.2") || !strings.Contains(body, "3.3.3.3") {
+ t.Fatalf("expected both completed ips in last-completed section, got:\n%s", body)
+ }
+ if !strings.Contains(body, "pass: 1") || !strings.Contains(body, "cancelled: 1") {
+ t.Fatalf("expected breakdown pass:1 cancelled:1, got:\n%s", body)
+ }
+}
+
+func TestIPsSubmitAddAndForceRecheck(t *testing.T) {
+ fake, caURL := newFakeControlAPI(t)
+ now := time.Now()
+ fake.ips = []ipQueueItem{
+ {IPAddress: "9.9.9.9", State: "done", OverallResult: "pass", AttemptNumber: 1, AggregatedAt: &now, UpdatedAt: now, CreatedAt: now},
+ }
+ ts := newTestServer(t, caURL)
+
+ // Add a brand-new address.
+ body := postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"1.2.3.4"}})
+ if !strings.Contains(body, "1.2.3.4") {
+ t.Fatalf("expected new address in re-rendered table, got:\n%s", body)
+ }
+
+ // Force-recheck the already-done address via the same endpoint.
+ body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {"9.9.9.9"}})
+ if !strings.Contains(body, "9.9.9.9") {
+ t.Fatalf("expected requeued address in table, got:\n%s", body)
+ }
+ if fake.ips[0].State != "queued" || fake.ips[0].AttemptNumber != 2 {
+ t.Fatalf("expected fake control-api state requeued with attempt_number=2, got %+v", fake.ips[0])
+ }
+
+ // Empty submission is a client error, surfaced via the banner, not a 500.
+ body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {""}})
+ if !strings.Contains(body, "error-banner-client") {
+ t.Fatalf("expected client error banner for empty submission, got:\n%s", body)
+ }
+}
+
+func TestIPRecheckSkippedInProgress(t *testing.T) {
+ fake, caURL := newFakeControlAPI(t)
+ now := time.Now()
+ fake.ips = []ipQueueItem{{IPAddress: "5.5.5.5", State: "checking", UpdatedAt: now, CreatedAt: now}}
+ ts := newTestServer(t, caURL)
+
+ body := postForm(t, ts, "POST", "/ips/5.5.5.5/recheck", nil)
+ // SubmitIPs succeeds (200) but the address itself lands in
+ // skipped_in_progress — the dashboard shouldn't claim success without
+ // qualification; the table must still show it untouched (still checking).
+ if !strings.Contains(body, "5.5.5.5") {
+ t.Fatalf("expected 5.5.5.5 still listed, got:\n%s", body)
+ }
+ if fake.ips[0].State != "checking" {
+ t.Fatalf("expected state untouched (checking), got %s", fake.ips[0].State)
+ }
+}
+
+func TestIPCancel(t *testing.T) {
+ fake, caURL := newFakeControlAPI(t)
+ now := time.Now()
+ fake.ips = []ipQueueItem{{IPAddress: "7.7.7.7", State: "checking", UpdatedAt: now, CreatedAt: now}}
+ ts := newTestServer(t, caURL)
+
+ body := postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
+ if !strings.Contains(body, "cancelled") {
+ t.Fatalf("expected cancelled badge after cancel, got:\n%s", body)
+ }
+ if fake.ips[0].OverallResult != "cancelled" {
+ t.Fatalf("expected fake control-api state cancelled, got %+v", fake.ips[0])
+ }
+
+ // Cancelling an already-terminal ip is a 409 from control-api, surfaced
+ // as a banner, not a crash.
+ body = postForm(t, ts, "POST", "/ips/7.7.7.7/cancel", nil)
+ if !strings.Contains(body, "error-banner") {
+ t.Fatalf("expected error banner for double-cancel, got:\n%s", body)
+ }
+}
+
+func TestValidatorsCRUD(t *testing.T) {
+ _, caURL := newFakeControlAPI(t)
+ ts := newTestServer(t, caURL)
+
+ body := postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-1"}})
+ if !strings.Contains(body, "val-1") || !strings.Contains(body, "port-1") {
+ t.Fatalf("expected new validator in table, got:\n%s", body)
+ }
+
+ // Conflict: creating the same validator_id again.
+ body = postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-x"}})
+ if !strings.Contains(body, "error-banner-client") {
+ t.Fatalf("expected client error banner on duplicate create, got:\n%s", body)
+ }
+
+ body = postForm(t, ts, "PUT", "/validators/val-1", map[string][]string{"os_port_id": {"port-2"}})
+ if !strings.Contains(body, "port-2") {
+ t.Fatalf("expected updated port in table, got:\n%s", body)
+ }
+
+ body = postForm(t, ts, "DELETE", "/validators/val-1", nil)
+ if strings.Contains(body, "val-1") {
+ t.Fatalf("expected validator removed after delete, got:\n%s", body)
+ }
+}
+
+func TestSitesPutAndConflict(t *testing.T) {
+ _, caURL := newFakeControlAPI(t)
+ ts := newTestServer(t, caURL)
+
+ body := postForm(t, ts, "PUT", "/sites/1", map[string][]string{"site_id": {"site-1"}})
+ if !strings.Contains(body, "site-1") {
+ t.Fatalf("expected site-1 assigned to slot 1, got:\n%s", body)
+ }
+
+ // Slot page always renders exactly 3 rows, including empty ones — count
+ // the per-slot PUT forms rather than raw
(the thead row has one too).
+ page := get(t, ts, "/sites")
+ if strings.Count(page, `hx-put="/sites/`) != 3 {
+ t.Fatalf("expected exactly 3 site slot rows, got:\n%s", page)
+ }
+
+ body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
+ if !strings.Contains(body, "error-banner-client") {
+ t.Fatalf("expected conflict banner assigning a taken site_id to another slot, got:\n%s", body)
+ }
+}
+
+func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
+ _, caURL := newFakeControlAPI(t)
+ ts := newTestServer(t, caURL)
+
+ body := postForm(t, ts, "POST", "/targets", map[string][]string{"name": {"web"}, "targets": {"https://a.test\nhttps://b.test"}})
+ if !strings.Contains(body, "web") {
+ t.Fatalf("expected new target group in table, got:\n%s", body)
+ }
+
+ body = postForm(t, ts, "POST", "/check-types", map[string][]string{"name": {"https"}, "enabled": {"1"}, "targets": {"web"}})
+ if !strings.Contains(body, "https") {
+ t.Fatalf("expected new check type in table, got:\n%s", body)
+ }
+
+ // Can't delete a target group still referenced by a check type.
+ body = postForm(t, ts, "DELETE", "/targets/web", nil)
+ if !strings.Contains(body, "error-banner-client") {
+ t.Fatalf("expected conflict banner deleting in-use target group, got:\n%s", body)
+ }
+
+ body = postForm(t, ts, "DELETE", "/check-types/https", nil)
+ if strings.Contains(body, "
https
") {
+ t.Fatalf("expected check type removed, got:\n%s", body)
+ }
+
+ body = postForm(t, ts, "DELETE", "/targets/web", nil)
+ if strings.Contains(body, "
web
") {
+ t.Fatalf("expected target group removable once unreferenced, got:\n%s", body)
+ }
+}
+
+func TestControlAPIUnreachable(t *testing.T) {
+ // Point the dashboard at an address nothing listens on, rather than a
+ // closed httptest.Server, to get a deterministic connection-refused
+ // transport failure.
+ ts := newTestServer(t, "http://127.0.0.1:1")
+ body := get(t, ts, "/overview")
+ if !strings.Contains(body, "error-banner-server") {
+ t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body)
+ }
+}
diff --git a/internal/dashboard/handlers_validators.go b/internal/dashboard/handlers_validators.go
new file mode 100644
index 0000000..2144901
--- /dev/null
+++ b/internal/dashboard/handlers_validators.go
@@ -0,0 +1,58 @@
+package dashboard
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type validatorsPageData struct {
+ PageData
+ Items []validatorDTO
+}
+
+func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
+ items, err := s.CA.ListValidators(r.Context())
+ data := validatorsPageData{Items: items}
+ data.ActiveNav = "validators"
+ data.Banner = bannerFor(err)
+ s.renderPage(w, "validators_page", data)
+}
+
+func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+ items, listErr := s.CA.ListValidators(r.Context())
+ if actionErr == nil {
+ actionErr = listErr
+ }
+ s.renderFragment(w, "validators_table", validatorsPageData{Items: items}, actionErr)
+}
+
+func (s *Server) handleValidatorCreate(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ s.renderValidatorsTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ id := r.PostFormValue("validator_id")
+ osPortID := r.PostFormValue("os_port_id")
+ if id == "" {
+ s.renderValidatorsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "validator_id обязателен"})
+ return
+ }
+ err := s.CA.CreateValidator(r.Context(), id, osPortID)
+ s.renderValidatorsTable(w, r, err)
+}
+
+func (s *Server) handleValidatorUpdate(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := r.ParseForm(); err != nil {
+ s.renderValidatorsTable(w, r, fmt.Errorf("invalid form: %w", err))
+ return
+ }
+ err := s.CA.UpdateValidator(r.Context(), id, r.PostFormValue("os_port_id"))
+ s.renderValidatorsTable(w, r, err)
+}
+
+func (s *Server) handleValidatorDelete(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ err := s.CA.DeleteValidator(r.Context(), id)
+ s.renderValidatorsTable(w, r, err)
+}
diff --git a/internal/dashboard/render.go b/internal/dashboard/render.go
new file mode 100644
index 0000000..16f5bcc
--- /dev/null
+++ b/internal/dashboard/render.go
@@ -0,0 +1,154 @@
+package dashboard
+
+import (
+ "errors"
+ "html/template"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Badge is the {class, label} pair rendered as a colored pill — see
+// static/dashboard.css for the .badge-* classes.
+type Badge struct{ Class, Label string }
+
+func ipBadge(state, result string) Badge {
+ switch state {
+ case "done", "failed":
+ switch result {
+ case "pass":
+ return Badge{"badge badge-pass", "pass"}
+ case "partial":
+ return Badge{"badge badge-partial", "partial"}
+ case "cancelled":
+ return Badge{"badge badge-cancelled", "cancelled"}
+ default:
+ return Badge{"badge badge-fail", "fail"}
+ }
+ case "queued":
+ return Badge{"badge badge-queued", "queued"}
+ default:
+ return Badge{"badge badge-inprogress", state}
+ }
+}
+
+func validatorBadge(state string) Badge {
+ switch state {
+ case "idle":
+ return Badge{"badge badge-idle", "idle"}
+ case "unreachable":
+ return Badge{"badge badge-unreachable", "unreachable"}
+ case "unregistered":
+ return Badge{"badge badge-unregistered", "unregistered"}
+ default:
+ return Badge{"badge badge-inprogress", state} // assigned / checking
+ }
+}
+
+// fmtTime accepts time.Time, *time.Time, or nil and renders a local
+// timestamp or an em-dash placeholder — covers both the value-typed
+// (CreatedAt, CheckedAt, ...) and pointer-typed (AssignedAt,
+// AggregatedAt, ...) timestamp fields in the DTOs without two template
+// funcs.
+func fmtTime(v interface{}) string {
+ switch t := v.(type) {
+ case time.Time:
+ if t.IsZero() {
+ return "—"
+ }
+ return t.Local().Format("2006-01-02 15:04:05")
+ case *time.Time:
+ if t == nil {
+ return "—"
+ }
+ return t.Local().Format("2006-01-02 15:04:05")
+ default:
+ return "—"
+ }
+}
+
+func derefStr(s *string) string {
+ if s == nil || *s == "" {
+ return "—"
+ }
+ return *s
+}
+
+var funcMap = template.FuncMap{
+ "ipBadge": ipBadge,
+ "validatorBadge": validatorBadge,
+ "fmtTime": fmtTime,
+ "deref": derefStr,
+ "join": strings.Join,
+}
+
+func parseTemplates() (*template.Template, error) {
+ return template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
+}
+
+// bannerData drives the shared error-banner partial (see templates/
+// layout.html's "banner_inner" block and templates/error_banner.html).
+// Client distinguishes a business-logic 4xx (rendered in yellow) from a
+// server-side 5xx or transport failure such as control-api being
+// unreachable (rendered in red).
+type bannerData struct {
+ Message string
+ Client bool
+}
+
+// PageData is embedded (anonymously) by every full-page template's data
+// struct so {{.Banner}}/{{.ActiveNav}} resolve via Go's promoted-field
+// rule in both the page template and the shared partials (page_header,
+// banner_inner) it includes. ActiveNav drives the nav bar's aria-current.
+type PageData struct {
+ Banner bannerData
+ ActiveNav string
+}
+
+func bannerFor(err error) bannerData {
+ if err == nil {
+ return bannerData{}
+ }
+ var ae *apiErr
+ if errors.As(err, &ae) {
+ return bannerData{Message: ae.Error(), Client: ae.Status >= 400 && ae.Status < 500}
+ }
+ return bannerData{Message: err.Error(), Client: false}
+}
+
+// renderPage renders a full page (extends "layout") for a plain GET
+// navigation. actionErr (if any — e.g. the primary control-api call for
+// this page failed) is surfaced via the embedded PageData.Banner, which
+// the caller must have already set via bannerFor.
+func (s *Server) renderPage(w http.ResponseWriter, name string, data interface{}) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ s.Log.Error("render page", "template", name, "err", err)
+ }
+}
+
+// renderFragment renders an htmx swap target (name) plus, appended to the
+// same response body, an out-of-band update of the shared #error-banner
+// (see templates/error_banner.html) reflecting actionErr — nil clears any
+// previously shown banner, matching htmx's id-based morph.
+//
+// The response status is always 200, deliberately: htmx's default
+// response-handling config only processes swaps (including OOB swaps) for
+// 2xx responses, and this dashboard needs the banner to render on every
+// response regardless of whether the underlying control-api call
+// succeeded. Because every mutating handler re-fetches the authoritative
+// list from control-api after attempting its mutation (success or not)
+// before calling renderFragment, the primary content is always a true
+// reflection of current state — success/failure is communicated by the
+// banner text and by whether the content actually changed, not by HTTP
+// status.
+func (s *Server) renderFragment(w http.ResponseWriter, name string, data interface{}, actionErr error) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ s.Log.Error("render fragment", "template", name, "err", err)
+ return
+ }
+ if err := s.tmpl.ExecuteTemplate(w, "error_banner", bannerFor(actionErr)); err != nil {
+ s.Log.Error("render error banner", "err", err)
+ }
+}
diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go
new file mode 100644
index 0000000..54c42ab
--- /dev/null
+++ b/internal/dashboard/routes.go
@@ -0,0 +1,41 @@
+package dashboard
+
+import "net/http"
+
+func (s *Server) routes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /{$}", s.handleIndex)
+
+ mux.HandleFunc("GET /overview", s.handleOverview)
+ mux.HandleFunc("GET /overview/fragment", s.handleOverviewFragment)
+
+ mux.HandleFunc("GET /ips", s.handleIPsPage)
+ mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail)
+ mux.HandleFunc("POST /ips", s.handleIPsSubmit)
+ mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck)
+ mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel)
+
+ mux.HandleFunc("GET /validators", s.handleValidatorsPage)
+ mux.HandleFunc("POST /validators", s.handleValidatorCreate)
+ mux.HandleFunc("PUT /validators/{id}", s.handleValidatorUpdate)
+ mux.HandleFunc("DELETE /validators/{id}", s.handleValidatorDelete)
+
+ mux.HandleFunc("GET /sites", s.handleSitesPage)
+ mux.HandleFunc("PUT /sites/{index}", s.handleSitePut)
+ mux.HandleFunc("DELETE /sites/{index}", s.handleSiteDelete)
+
+ mux.HandleFunc("GET /targets", s.handleTargetsPage)
+ mux.HandleFunc("POST /targets", s.handleTargetCreate)
+ mux.HandleFunc("PUT /targets/{group}", s.handleTargetUpdate)
+ mux.HandleFunc("DELETE /targets/{group}", s.handleTargetDelete)
+
+ mux.HandleFunc("GET /check-types", s.handleCheckTypesPage)
+ mux.HandleFunc("POST /check-types", s.handleCheckTypeCreate)
+ mux.HandleFunc("PUT /check-types/{name}", s.handleCheckTypeUpdate)
+ mux.HandleFunc("DELETE /check-types/{name}", s.handleCheckTypeDelete)
+
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS())))
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/overview", http.StatusFound)
+}
diff --git a/internal/dashboard/server.go b/internal/dashboard/server.go
new file mode 100644
index 0000000..cfc5088
--- /dev/null
+++ b/internal/dashboard/server.go
@@ -0,0 +1,54 @@
+// Package dashboard implements the admin dashboard's HTTP surface: a
+// server-rendered (html/template + htmx + Alpine.js) web UI giving full
+// coverage of control-api's /api/v1/admin/* API. It never talks to
+// internal/db directly and has no state of its own — every page and
+// fragment is computed fresh, on each request, from control-api's API via
+// the client in client.go.
+package dashboard
+
+import (
+ "html/template"
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+type Config struct {
+ ControlAPIBaseURL string
+ ControlAPITimeout time.Duration
+ LastCompletedCount int
+ OverviewPollIntervalS int
+}
+
+type Server struct {
+ CA *client
+ Cfg Config
+ tmpl *template.Template
+ Log *slog.Logger
+}
+
+func New(cfg Config, log *slog.Logger) (*Server, error) {
+ tmpl, err := parseTemplates()
+ if err != nil {
+ return nil, err
+ }
+ return &Server{
+ CA: newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout),
+ Cfg: cfg,
+ tmpl: tmpl,
+ Log: log,
+ }, nil
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ s.routes(mux)
+ return loggingMiddleware(s.Log, mux)
+}
+
+func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ next.ServeHTTP(w, r)
+ log.Debug("request", "method", r.Method, "path", r.URL.Path)
+ })
+}
diff --git a/internal/dashboard/static/dashboard.css b/internal/dashboard/static/dashboard.css
new file mode 100644
index 0000000..1b76529
--- /dev/null
+++ b/internal/dashboard/static/dashboard.css
@@ -0,0 +1,59 @@
+/* Small additions Pico's classless build doesn't cover: color-coded state
+ badges, the error banner, and a compact stat-tile grid for the overview
+ page. Everything else relies on Pico's classless defaults. */
+
+.badge {
+ display: inline-block;
+ padding: 0.15rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+.badge-queued { background: #e2e8f0; color: #334155; }
+.badge-inprogress { background: #dbeafe; color: #1d4ed8; }
+.badge-pass { background: #dcfce7; color: #15803d; }
+.badge-partial { background: #ffedd5; color: #c2410c; }
+.badge-fail { background: #fee2e2; color: #b91c1c; }
+.badge-cancelled { background: #ede9fe; color: #6d28d9; }
+.badge-idle { background: #dcfce7; color: #15803d; }
+.badge-unreachable { background: #fee2e2; color: #b91c1c; }
+.badge-unregistered { background: #e2e8f0; color: #334155; }
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+.stat-tile {
+ border: 1px solid var(--pico-muted-border-color, #ddd);
+ border-radius: 0.5rem;
+ padding: 0.75rem 1rem;
+ text-align: center;
+}
+.stat-tile .value { display: block; font-size: 1.6rem; font-weight: 700; }
+.stat-tile .label { display: block; font-size: 0.8rem; opacity: 0.75; }
+
+#error-banner:empty { display: none; }
+.error-banner {
+ padding: 0.6rem 1rem;
+ margin-bottom: 1rem;
+ border-radius: 0.4rem;
+ font-size: 0.9rem;
+}
+.error-banner-client { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
+.error-banner-server { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
+
+nav.dashboard-nav ul { display: flex; gap: 1.25rem; list-style: none; padding: 0; margin: 0 0 1.5rem 0; }
+nav.dashboard-nav a[aria-current="page"] { font-weight: 700; text-decoration: underline; }
+
+.actions-cell { white-space: nowrap; }
+.actions-cell button { margin-right: 0.35rem; font-size: 0.85rem; padding: 0.25rem 0.6rem; }
+.actions-cell button.secondary { --pico-primary-background: #64748b; }
+.actions-cell button.contrast { --pico-primary-background: #b91c1c; }
+
+table.compact th, table.compact td { padding: 0.4rem 0.6rem; }
+
+.muted { opacity: 0.65; font-size: 0.85rem; }
diff --git a/internal/dashboard/static/vendor/VENDOR.md b/internal/dashboard/static/vendor/VENDOR.md
new file mode 100644
index 0000000..68da71a
--- /dev/null
+++ b/internal/dashboard/static/vendor/VENDOR.md
@@ -0,0 +1,15 @@
+# Vendored frontend assets
+
+Downloaded once and committed as-is — the dashboard binary must work fully
+offline, with no runtime CDN dependency (see `docs/PLAN_ADMIN_DASHBOARD.md`).
+Do not "update" these files casually; bump deliberately, in their own
+commit, if a newer version is actually needed.
+
+| File | Source | Version | Fetched |
+|---|---|---|---|
+| `htmx.min.js` | https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js | 2.0.10 | 2026-08-23 |
+| `alpine.min.js` | https://unpkg.com/alpinejs@3.16.2/dist/cdn.min.js | 3.16.2 | 2026-08-23 |
+| `pico.classless.min.css` | https://unpkg.com/@picocss/pico@2.1.1/css/pico.classless.min.css | 2.1.1 | 2026-08-23 |
+
+Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Pico CSS (MIT) — all
+permit vendoring/redistribution unmodified.
diff --git a/internal/dashboard/static/vendor/alpine.min.js b/internal/dashboard/static/vendor/alpine.min.js
new file mode 100644
index 0000000..2de9655
--- /dev/null
+++ b/internal/dashboard/static/vendor/alpine.min.js
@@ -0,0 +1,21 @@
+(()=>{var _t=!1,gt=!1,$=[],xt=-1,je=!1,yt=!1;function mr(e){ri(e)}function _r(){yt=!0}function gr(){yt=!1,yr()}function ri(e){$.includes(e)||($.push(e),e._x_schedulerPriority!==void 0&&(je=!0)),yr()}function xr(e){let t=$.indexOf(e);t!==-1&&t>xt&&$.splice(t,1)}function yr(){if(!gt&&!_t){if(yt)return;_t=!0,queueMicrotask(ni)}}function ni(){_t=!1,gt=!0;for(let e=0;e<$.length;e++)je&&ii(e),$[e](),xt=e;$.length=0,xt=-1,je=!1,gt=!1}function ii(e){let t=new Map,r=$.slice(e).sort((n,i)=>oi(n,i,t));for(let n=0;ne.effect(t,{scheduler:r=>{bt?mr(r):r()}}),Et=e.raw}function vt(e){P=e}function vr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?si++:void 0,s=P(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),B(s))},s},()=>{t()}]}function Fe(e,t){let r=!0,n,i,o=P(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>B(o)}async function wr(e){_r();try{await e(),await Promise.resolve()}finally{gr()}}var Sr=[],Ar=[],Or=[];function Tr(e){Or.push(e)}function ce(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Ar.push(t))}function $e(e){Sr.push(e)}function Be(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function wt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Nr(e){for(e._x_effects?.forEach(xr);e._x_cleanups?.length;)e._x_cleanups.pop()()}var St=new MutationObserver(Nt),At=!1;function Ee(){St.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),At=!0}function Ot(){ai(),St.disconnect(),At=!1}var be=[];function ai(){let e=St.takeRecords();be.push(()=>e.length>0&&Nt(e));let t=be.length;queueMicrotask(()=>{if(be.length===t)for(;be.length>0;)be.shift()()})}function h(e){if(!At)return e();Ot();let t=e();return Ee(),t}var Tt=!1,Ve=[];function Cr(){Tt=!0}function Rr(){Tt=!1,Nt(Ve),Ve=[]}function Nt(e){if(Tt){Ve=Ve.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{wt(s,o)}),n.forEach((o,s)=>{Sr.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Ar.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function He(e){return L(H(e))}function k(e,t,r){return e._x_dataStack=[t,...H(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function H(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?H(e.host):e.parentNode?H(e.parentNode):[]}function L(e){return new Proxy({objects:e},ci)}function Dr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:Dr(Object.getPrototypeOf(e),t)}var ci={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?li:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=Dr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function li(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function le(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=o===""?s:`${o}.${s}`;typeof a=="object"&&a!==null&&a._x_interceptor?i[s]=a.initialize(e,l,s,t):r(a)&&a!==i&&!(a instanceof Element)&&n(a,l)})};return n(e)}function Ue(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o,s){return e(this.initialValue,()=>fi(n,i),a=>Ct(n,i,a),i,o,s)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a,c)=>{let l=n.initialize(o,s,a,c);return r.initialValue=l,i(o,s,a,c)}}else r.initialValue=n;return r}}function fi(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Ct(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Ct(e[t[0]],t.slice(1),r)}}var Mr={};function b(e,t){Mr[e]=t}function q(e,t){let r=ui(t);return Object.entries(Mr).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function ui(e){let[t,r]=Rt(e),n={interceptor:Ue,...t};return ce(e,r),n}function Pr(e,t,r,...n){try{return r(...n)}catch(i){fe(i,e,t)}}function fe(...e){return kr(...e)}var kr=pi;function Lr(e){kr=e}function pi(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,t),setTimeout(()=>{throw e},0)}var ue=!0;function ze(e){let t=ue;ue=!1;let r=e();return ue=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Ir(...e)}var Ir=()=>{};function jr(e){Ir=e}var Fr;function Vr(e){Fr=e}function $r(e,t){let r={};q(r,e);let n=[r,...H(e)],i=typeof t=="function"?di(n,t):mi(n,t,e);return Pr.bind(null,e,t,i)}function di(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!ue){ve(r,t,L([n,...e]),i);return}let s=t.apply(L([n,...e]),i);ve(r,s)}}var Dt={};function hi(e,t){if(Dt[e])return Dt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return fe(s,t,e),Promise.resolve()}})();return Dt[e]=o,o}function mi(e,t,r){let n=hi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=L([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>fe(f,r,t));n.finished?(ve(i,n.result,c,s,r),n.result=void 0):l.then(f=>{ve(i,f,c,s,r)}).catch(f=>fe(f,r,t)).finally(()=>n.result=void 0)}}}function ve(e,t,r,n,i){if(ue&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>ve(e,s,r,n)).catch(s=>fe(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Br(...e){return Fr(...e)}function Hr(e,t,r={}){let n={};q(n,e);let i=[n,...H(e)],o=L([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&ue?l.apply(o,s):l}}var kt="x-";function N(e=""){return kt+e}function Ur(e){kt=e}var We={};function d(e,t){return We[e]=t,{before(r){if(!We[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=X.indexOf(r);X.splice(n>=0?n:X.indexOf("DEFAULT"),0,e)}}}function zr(e){return Object.keys(We).includes(e)}function Se(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Lt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(qr((o,s)=>n[o]=s)).filter(Yr).map(gi(n,r)).sort(xi).map(o=>_i(e,o))}function Lt(e){return Array.from(e).map(qr()).filter(t=>!Yr(t))}var Mt=!1,we=new Map,Wr=Symbol();function Kr(e){Mt=!0;let t=Symbol();Wr=t,we.set(t,[]);let r=()=>{for(;we.get(t).length;)we.get(t).shift()();we.delete(t)},n=()=>{Mt=!1,r()};e(r),n()}function Rt(e){let t=[],r=a=>t.push(a),[n,i]=vr(e);return t.push(i),[{Alpine:U,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function _i(e,t){let r=()=>{},n=We[t.type]||r,[i,o]=Rt(e);Be(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Mt?we.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ke=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),qe=e=>e;function qr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Gr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Gr=[];function pe(e){Gr.push(e)}function Yr({name:e}){return Jr().test(e)}var Jr=()=>new RegExp(`^${kt}([^:^.]+)\\b`);function gi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Jr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var Pt="DEFAULT",X=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",Pt,"teleport"];function xi(e,t){let r=X.indexOf(e.type)===-1?Pt:e.type,n=X.indexOf(t.type)===-1?Pt:t.type;return X.indexOf(r)-X.indexOf(n)}function Z(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)I(n,t,!1),n=n.nextElementSibling}function w(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Xr=!1;function Zr(){Xr&&w("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Xr=!0,document.body||w("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
+
+{{end}}
+
+{{define "page_header"}}
+
+
+
+{{end}}
+
+{{define "banner_inner"}}{{if .Message}}