# План: `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. Ручной браузерный смоук-тест (обязателен, не пропускается)