Files
dns-autoresolver/docs/superpowers/plans/2026-07-04-phase3-scheduler-notify-metrics.md

500 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3: Расписание · Уведомления · Метрики — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Frontend-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Периодические проверки доменов по расписанию (in-process планировщик), уведомления Telegram/Webhook при смене статуса домена, Prometheus-метрики (`/metrics`). Планировщик только проверяет и уведомляет — apply остаётся ручным.
**Architecture:** `internal/notify` (Notifier + Telegram/Webhook на `net/http` + Dispatcher с расшифровкой секрета). `internal/metrics` (custom Prometheus registry). `internal/scheduler` (goroutine-тикер → due-проекты → `service.Check` каждого домена → `check_run` + `last_check_status` → при смене статуса `Dispatcher.Send`). REST CRUD расписания/каналов + история под RequireAuth+RequireProjectAccess. Планировщик и `/metrics` поднимаются в `cmd/server`.
**Tech Stack:** Go, `github.com/prometheus/client_golang`, `net/http` (Telegram Bot API + Webhook), pgx/sqlc, chi; React + TanStack Query; Vitest.
## Global Constraints
- Module `github.com/vasyakrg/dns-autoresolver`. Фронт `web/` (npm). sqlc в `~/go/bin` (`export PATH="$(go env GOPATH)/bin:$PATH"`). Store-тесты — Docker.
- Планировщик **НЕ применяет** изменения — только `service.Check` + сохранение результата + уведомление.
- **Гранулярность — на проект**: одно расписание на проект (`schedules.project_id UNIQUE`), проверяет все домены проекта.
- **Уведомление при СМЕНЕ статуса** домена (`unknown|in_sync|drift|error`) — сравнение с `domains.last_check_status`; при неизменном статусе уведомление НЕ шлётся (идемпотентность).
- Статус домена: `len(cs.Actionable())>0``drift`, иначе `in_sync`; ошибка `service.Check``error`.
- Каналы: `telegram` (Bot API `sendMessage`, `bot_token` шифруется `crypto.Cipher`, config `{chat_id}`), `webhook` (HTTP POST JSON, config `{url}`).
- `bot_token`/секреты каналов НИКОГДА не в ответах/логах. `/metrics` **публичный**, без секретов/PII (только агрегаты по `status`/`channel`).
- Всё scoped по проекту (RequireProjectAccess из Фазы 2 сохраняется; IDOR не переоткрывать).
- Секрет учётки/`password_hash` не регрессировать. Каждая задача — зелёные тесты (Go: `go test`; фронт: `npm run test`) и коммит. `[Docker]` — нужен Docker.
---
### Task 1 [Docker]: Миграция + store (schedules, channels, domain status)
**Files:**
- Create: `internal/store/migrations/0004_schedule_notify.sql`
- Create: `internal/store/queries/schedules.sql`, `internal/store/queries/channels.sql`; Modify: `internal/store/queries/domains.sql`
- Regenerate `internal/store/db/*`; Modify: `internal/store/tenant.go`
- Create: `internal/store/schedule_test.go`
**Interfaces:**
- Produces (методы `*Store`; uuid — google/uuid):
- типы `Schedule{ID, ProjectID uuid.UUID; IntervalSeconds int32; Enabled bool; LastRunAt *time.Time}`, `Channel{ID, ProjectID uuid.UUID; Type string; Config json.RawMessage; SecretEnc string; Enabled bool}`
- `GetSchedule(ctx, projectID) (Schedule, error)` (нет строки → создать дефолт или ErrNoRows — см. ниже); `UpsertSchedule(ctx, projectID uuid.UUID, interval int32, enabled bool) (Schedule, error)`
- `ListDueSchedules(ctx, now time.Time) ([]Schedule, error)``enabled AND (last_run_at IS NULL OR last_run_at + interval <= now)`
- `TouchScheduleRun(ctx, projectID uuid.UUID, at time.Time) error` — set `last_run_at`
- `CreateChannel(ctx, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (Channel, error)`; `ListChannels(ctx, projectID) ([]Channel, error)`; `GetChannel(ctx, id, projectID) (Channel, error)`; `DeleteChannel(ctx, id, projectID uuid.UUID) error`; `ListEnabledChannels(ctx, projectID) ([]Channel, error)`
- `GetDomainStatus(ctx, domainID uuid.UUID) (string, error)`; `SetDomainStatus(ctx, domainID uuid.UUID, status string) error`; `ListDomains` уже есть (добавить `LastCheckStatus` в тип `Domain`)
- [ ] **Step 1: Миграция**
`internal/store/migrations/0004_schedule_notify.sql`:
```sql
-- +goose Up
CREATE TABLE schedules (
id uuid PRIMARY KEY,
project_id uuid NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
interval_seconds int NOT NULL DEFAULT 3600,
enabled boolean NOT NULL DEFAULT false,
last_run_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE notification_channels (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type text NOT NULL,
config jsonb NOT NULL,
secret_enc text NOT NULL DEFAULT '',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE domains ADD COLUMN last_check_status text NOT NULL DEFAULT 'unknown';
-- +goose Down
ALTER TABLE domains DROP COLUMN last_check_status;
DROP TABLE notification_channels;
DROP TABLE schedules;
```
- [ ] **Step 2: Запросы**
`schedules.sql`:
```sql
-- name: GetSchedule :one
SELECT * FROM schedules WHERE project_id = $1;
-- name: UpsertSchedule :one
INSERT INTO schedules (id, project_id, interval_seconds, enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (project_id) DO UPDATE SET interval_seconds = $3, enabled = $4
RETURNING *;
-- name: ListDueSchedules :many
SELECT * FROM schedules
WHERE enabled AND (last_run_at IS NULL OR last_run_at + (interval_seconds || ' seconds')::interval <= $1);
-- name: TouchScheduleRun :exec
UPDATE schedules SET last_run_at = $2 WHERE project_id = $1;
```
`channels.sql`:
```sql
-- name: CreateChannel :one
INSERT INTO notification_channels (id, project_id, type, config, secret_enc)
VALUES ($1, $2, $3, $4, $5) RETURNING *;
-- name: ListChannels :many
SELECT * FROM notification_channels WHERE project_id = $1 ORDER BY created_at;
-- name: ListEnabledChannels :many
SELECT * FROM notification_channels WHERE project_id = $1 AND enabled ORDER BY created_at;
-- name: GetChannel :one
SELECT * FROM notification_channels WHERE id = $1 AND project_id = $2;
-- name: DeleteChannel :exec
DELETE FROM notification_channels WHERE id = $1 AND project_id = $2;
```
`domains.sql` (добавить):
```sql
-- name: GetDomainStatus :one
SELECT last_check_status FROM domains WHERE id = $1;
-- name: SetDomainStatus :exec
UPDATE domains SET last_check_status = $2 WHERE id = $1;
```
- [ ] **Step 3: sqlc generate**
Run: `export PATH="$(go env GOPATH)/bin:$PATH" && sqlc generate``internal/store/db/` обновлён; `go build ./internal/store/db/`.
- [ ] **Step 4: Store-обёртки**
Добавить в `internal/store/tenant.go` типы `Schedule`/`Channel` и методы из Interfaces. `config``json.RawMessage` (jsonb без override → `[]byte`). `last_run_at`/`interval` — сверить сгенерированные типы (`pgtype.Timestamptz`/`int32`), конвертировать. Добавить `LastCheckStatus string` в тип `Domain` и в `toDomain` (ListDomains теперь возвращает статус — обновить `SELECT`/scan, sqlc сам добавит колонку).
> Реализатор: `GetSchedule` при отсутствии строки — верни `ErrNoRows` (обёртку не создавать); API-слой (Task 5) вернёт дефолт `{interval:3600, enabled:false}`.
- [ ] **Step 5: Интеграционные тесты**
`internal/store/schedule_test.go` [Docker]: UpsertSchedule (insert затем update того же project — одна строка, поля обновлены); ListDueSchedules (enabled с last_run_at=nil → в выборке; disabled → нет; недавно запущенный (last_run_at=now) с большим интервалом → нет); TouchScheduleRun; CreateChannel/ListChannels/GetChannel/DeleteChannel scoped по project; GetChannel чужого project → ErrNoRows; SetDomainStatus/GetDomainStatus round-trip.
- [ ] **Step 6: Проверки, коммит**
Run: `go test ./internal/store/... -v` (Docker); `go build ./...`; `go vet ./...`.
```bash
git add internal/store/migrations/0004_schedule_notify.sql internal/store/queries/ internal/store/db/ internal/store/tenant.go internal/store/schedule_test.go
git commit -m "feat(store): schedules, notification_channels, domain last_check_status + методы"
```
---
### Task 2: `internal/notify` (Notifier + Telegram + Webhook + Dispatcher)
**Files:**
- Create: `internal/notify/notify.go`, `internal/notify/telegram.go`, `internal/notify/webhook.go`, `internal/notify/dispatch.go`
- Create: `internal/notify/notify_test.go`
**Interfaces:**
- Produces:
- `type Event struct { Project, Domain, Status, Summary string; At time.Time }`
- `type Notifier interface { Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error }`
- `type Telegram struct { BaseURL string; HTTP *http.Client }` (BaseURL default `https://api.telegram.org`); реализует Notifier (config `{chat_id}`, secret=bot_token)
- `type Webhook struct { HTTP *http.Client }`; реализует Notifier (config `{url}`, secret игнорируется/подпись)
- `type ChannelStore interface { ListEnabledChannels(ctx, projectID uuid.UUID) ([]store.Channel, error) }`; `type Decryptor interface { Decrypt(enc string) ([]byte, error) }`
- `type Dispatcher struct { ... }`; `func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher` (регистрирует telegram+webhook); `func (*Dispatcher) Send(ctx, projectID uuid.UUID, ev Event) error` — по каждому enabled-каналу проекта выбирает Notifier, расшифровывает secret, шлёт
- [ ] **Step 1: Падающие тесты (httptest)**
`internal/notify/notify_test.go`:
- Telegram: `Send` шлёт POST на `${BaseURL}/bot${secret}/sendMessage` с `chat_id` из config и текстом (Summary/Domain/Status в теле); сервер 200 → nil; сервер 500 → ошибка.
- Webhook: `Send` шлёт POST на `url` из config с JSON Event; 2xx → nil; не-2xx → ошибка.
- Dispatcher: мок ChannelStore возвращает 2 канала (telegram+webhook), мок Decryptor; `Send` вызывает оба Notifier (перехват через подменённые BaseURL/URL на httptest); ошибка одного канала не срывает другой (агрегированная ошибка/лог, но обе попытки сделаны).
- [ ] **Step 2: Реализовать**
`notify.go` — Event, Notifier, ошибки. `telegram.go`:
```go
type Telegram struct {
BaseURL string
HTTP *http.Client
}
func (t *Telegram) Send(ctx context.Context, cfg json.RawMessage, secret string, ev Event) error {
var c struct{ ChatID string `json:"chat_id"` }
if err := json.Unmarshal(cfg, &c); err != nil { return err }
body, _ := json.Marshal(map[string]string{
"chat_id": c.ChatID,
"text": fmt.Sprintf("[%s] %s → %s\n%s", ev.Project, ev.Domain, ev.Status, ev.Summary),
})
url := t.BaseURL + "/bot" + secret + "/sendMessage"
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := t.HTTP.Do(req)
if err != nil { return err }
defer resp.Body.Close()
if resp.StatusCode >= 300 { return fmt.Errorf("telegram: status %d", resp.StatusCode) }
return nil
}
```
`webhook.go` — аналогично, POST JSON `ev` на `cfg.url`, не-2xx → ошибка. `dispatch.go`:
```go
type Dispatcher struct {
store ChannelStore
cipher Decryptor
byType map[string]Notifier
}
func NewDispatcher(store ChannelStore, cipher Decryptor) *Dispatcher {
return &Dispatcher{store: store, cipher: cipher, byType: map[string]Notifier{
"telegram": &Telegram{BaseURL: "https://api.telegram.org", HTTP: &http.Client{Timeout: 15 * time.Second}},
"webhook": &Webhook{HTTP: &http.Client{Timeout: 15 * time.Second}},
}}
}
func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) error {
channels, err := d.store.ListEnabledChannels(ctx, projectID)
if err != nil { return err }
var errs []error
for _, ch := range channels {
n, ok := d.byType[ch.Type]
if !ok { continue }
secret := ""
if ch.SecretEnc != "" {
b, err := d.cipher.Decrypt(ch.SecretEnc)
if err != nil { errs = append(errs, err); continue }
secret = string(b)
}
if err := n.Send(ctx, ch.Config, secret, ev); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
```
(Для тестов подмени BaseURL/URL — сделай `byType` настраиваемым или экспортируй сеттер; в тесте создавай Dispatcher с httptest-URL напрямую.)
- [ ] **Step 3: Тесты зелёные, коммит**
Run: `go test ./internal/notify/ -v`; `go build ./...`; `go vet ./...`.
```bash
git add internal/notify/
git commit -m "feat(notify): Telegram/Webhook нотификаторы + Dispatcher по каналам проекта"
```
---
### Task 3: `internal/metrics` (Prometheus)
**Files:**
- Create: `internal/metrics/metrics.go`, `internal/metrics/metrics_test.go`
**Interfaces:**
- Produces:
- `type Metrics struct { Registry *prometheus.Registry; ChecksTotal *prometheus.CounterVec; CheckDuration prometheus.Histogram; DriftDomains prometheus.Gauge; NotificationsTotal *prometheus.CounterVec }`
- `func New() *Metrics` — custom registry + Go/Process collectors + метрики через `promauto.With(reg)`
- `func (m *Metrics) Handler() http.Handler``promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{})`
- хелперы: `ObserveCheck(status string, dur time.Duration)`, `SetDrift(n int)`, `IncNotification(channel, status string)`
- [ ] **Step 1: go get prometheus**
Run: `go get github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/promauto github.com/prometheus/client_golang/prometheus/promhttp github.com/prometheus/client_golang/prometheus/collectors`
- [ ] **Step 2: Падающий тест (testutil)**
`internal/metrics/metrics_test.go`:
```go
package metrics
import (
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func TestMetricsRecord(t *testing.T) {
m := New()
m.ObserveCheck("drift", 100*time.Millisecond)
m.ObserveCheck("in_sync", 50*time.Millisecond)
m.IncNotification("telegram", "ok")
m.SetDrift(3)
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues("drift")); got != 1 {
t.Fatalf("checks drift = %v", got)
}
if got := testutil.ToFloat64(m.DriftDomains); got != 3 {
t.Fatalf("drift gauge = %v", got)
}
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "ok")); got != 1 {
t.Fatalf("notif = %v", got)
}
}
func TestHandlerExposesMetrics(t *testing.T) {
m := New()
m.ObserveCheck("in_sync", time.Millisecond)
rec := httptest.NewRecorder()
m.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "dns_ar_checks_total") {
t.Fatalf("metrics not exposed: %d", rec.Code)
}
}
```
(добавь импорт `net/http/httptest`.)
- [ ] **Step 3: Реализовать metrics.go**
`New()``reg := prometheus.NewRegistry()`; `reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(...))`; метрики через `promauto.With(reg).NewCounterVec/NewHistogram/NewGauge` с именами `dns_ar_checks_total{status}`, `dns_ar_check_duration_seconds`, `dns_ar_drift_domains`, `dns_ar_notifications_total{channel,status}`. Хелперы инкрементируют.
- [ ] **Step 4: Тесты зелёные, коммит**
Run: `go test ./internal/metrics/ -v`; `go build ./...`; `go vet ./...`.
```bash
git add internal/metrics/ go.mod go.sum
git commit -m "feat(metrics): Prometheus registry (checks/drift/notifications) + /metrics handler"
```
---
### Task 5: REST API — schedule/channels/history (парал. с T2/T3 после T1)
> Нумерация: T4 (scheduler) зависит от T2/T3, поэтому идёт после. T5 зависит только от T1 — может выполняться параллельно с T2/T3.
**Files:**
- Create: `internal/api/schedule_handlers.go`, `internal/api/schedule_test.go`
- Modify: `internal/api/api.go` (роуты + интерфейс расширить), `internal/api/tenant_dto.go`
**Interfaces:**
- Расширить `TenantStore` (или отдельный интерфейс `ScheduleStore`) методами из T1: GetSchedule/UpsertSchedule, CreateChannel/ListChannels/GetChannel/DeleteChannel, история (`ListCheckRuns(ctx, domainID, projectID) ([]CheckRun, error)` — добавить в T1 при необходимости; или в этой задаче).
- Produces хендлеры: `GET/PUT /schedule`, `POST/GET/DELETE /channels`, `POST /channels/{cid}/test`, `GET /domains/{did}/history`; DTO без секрета.
- [ ] **Step 1: Падающий тест (httptest + мок)**
`schedule_test.go`:
- `GET /schedule` (нет строки → дефолт `{intervalSeconds:3600, enabled:false}`); `PUT /schedule {intervalSeconds, enabled}` → UpsertSchedule; интервал валидируется (>=60).
- `POST /channels {type, config, secret}` → CreateChannel (secret шифруется через cipher, config сохраняется); ответ БЕЗ secret. `GET /channels` без секретов. `DELETE`.
- `POST /channels/{cid}/test` → Dispatcher/Notifier тест-отправка (мок) → 200/ошибка канала.
- `GET /domains/{did}/history` → список check_runs. pid/did из контекста middleware.
- [ ] **Step 2: Реализовать хендлеры**
`schedule_handlers.go`: `pid := projectIDFrom(ctx)`. GET schedule → store.GetSchedule (ErrNoRows → дефолт-DTO). PUT → валидировать interval>=60 → UpsertSchedule. channels: POST — `cipher.Encrypt(secret)` → CreateChannel; response без secret (тип `channelResponse{id,type,config,enabled}`). test — загрузить канал, расшифровать, Notifier.Send тестового Event → 200 или 502 с ошибкой (без раскрытия секрета). history — ListCheckRuns (добавить query/метод, если нет).
Роуты в `api.go` под существующим `/api/v1/projects/{pid}` (уже под RequireAuth+RequireProjectAccess):
```go
r.Get("/schedule", a.handleGetSchedule)
r.Put("/schedule", a.handlePutSchedule)
r.Route("/channels", func(r chi.Router) {
r.Post("/", a.handleCreateChannel)
r.Get("/", a.handleListChannels)
r.Route("/{cid}", func(r chi.Router) {
r.Delete("/", a.handleDeleteChannel)
r.Post("/test", a.handleTestChannel)
})
})
// history — внутри domains/{did}
r.Get("/history", a.handleDomainHistory) // под /domains/{did}
```
`API` получает поля `Schedule ScheduleStore`, `Dispatch *notify.Dispatcher` (или узкий интерфейс для теста).
- [ ] **Step 3: Тесты зелёные, коммит**
Run: `go test ./internal/api/ -run 'Schedule|Channel|History' -v`; `go build ./...`; `go vet ./...`.
```bash
git add internal/api/
git commit -m "feat(api): CRUD расписания/каналов + тест-отправка + история проверок"
```
---
### Task 4: `internal/scheduler` (in-process планировщик)
**Files:**
- Create: `internal/scheduler/scheduler.go`, `internal/scheduler/scheduler_test.go`
**Interfaces:**
- Consumes: store (ListDueSchedules/TouchScheduleRun/ListDomains/GetDomainStatus/SetDomainStatus/SaveCheckRun), `service.DomainService` (Check), `notify.Dispatcher`, `metrics.Metrics`
- Produces:
- узкие интерфейсы `SchedStore` (нужные методы), `Checker { Check(ctx, projectID, domainID uuid.UUID) (diff.Changeset, error) }`, `NotifySender { Send(ctx, projectID uuid.UUID, ev notify.Event) error }`
- `type Scheduler struct { ... }`; `func New(store SchedStore, checker Checker, notifier NotifySender, m *metrics.Metrics) *Scheduler`
- `func (s *Scheduler) Run(ctx context.Context, tick time.Duration)` — цикл по `time.Ticker`, до `ctx.Done()`
- `func (s *Scheduler) RunOnce(ctx context.Context, now time.Time) error` — одна итерация (для теста): due-проекты → домены → Check → статус → notify
- [ ] **Step 1: Падающий тест (мок, RunOnce)**
`scheduler_test.go`: мок SchedStore (1 due-проект с 2 домена), мок Checker (домен A → Changeset с Actionable→drift; домен B → пустой→in_sync), мок NotifySender (записывает Events), metrics.New().
- `RunOnce`: для домена A статус меняется unknown→drift → Notify отправлен; для B unknown→in_sync → (по политике: in_sync из unknown — уведомлять? реши: уведомлять при ЛЮБОЙ смене, включая →in_sync после drift; из unknown→in_sync — НЕ спамить (первичная синхронизация без тревоги). Тест фиксирует: A (drift) уведомляет, B (in_sync из unknown) — нет). SaveCheckRun вызван для обоих. TouchScheduleRun вызван. Метрики: ChecksTotal drift=1, in_sync=1.
- Идемпотентность: повторный RunOnce при неизменном статусе A (drift→drift) → повторного Notify НЕТ.
- Ошибка Check домена → статус error, метрика error, уведомление (error — тревожное, уведомлять при смене в error).
> Политика уведомлений (зафиксировать в реализации и тесте): уведомлять при переходе в `drift` или `error`, и при восстановлении `drift→in_sync` (resolved). Переход `unknown→in_sync` — без уведомления (первичная норма). `unknown→drift`/`unknown→error` — уведомлять.
- [ ] **Step 2: Реализовать scheduler.go**
`RunOnce`: `due := store.ListDueSchedules(ctx, now)`; для каждого расписания — `domains := store.ListDomains(ctx, projectID)`; для каждого домена: старт таймера, `cs, err := checker.Check(ctx, projectID, domainID)`; вычислить `newStatus` (err→"error", Actionable>0→"drift", иначе "in_sync"); `m.ObserveCheck(newStatus, dur)`; `prev := store.GetDomainStatus`; `store.SaveCheckRun`; `store.SetDomainStatus(newStatus)`; если `shouldNotify(prev, newStatus)``notifier.Send(ctx, projectID, Event{...})` + `m.IncNotification`; после всех доменов проекта — `store.TouchScheduleRun(projectID, now)`; обновить `m.SetDrift(текущее число drift-доменов)`. `shouldNotify`: true если `newStatus∈{drift,error}` и `newStatus!=prev`, или `prev==drift && newStatus==in_sync`. `Run`: `ticker := time.NewTicker(tick)`; loop `select ctx.Done()/ticker.C → RunOnce(ctx, time.Now())` (ошибки логировать, не падать).
- [ ] **Step 3: Тесты зелёные, коммит**
Run: `go test ./internal/scheduler/ -v`; `go build ./...`; `go vet ./...`.
```bash
git add internal/scheduler/
git commit -m "feat(scheduler): in-process планировщик проверок + смена статуса + уведомления + метрики"
```
---
### Task 6: Wiring cmd/server (планировщик + /metrics + graceful shutdown)
**Files:**
- Modify: `cmd/server/main.go`
**Interfaces:**
- Собирает: `metrics.New()`, `notify.NewDispatcher(st, cipher)`, `scheduler.New(st, svc, dispatcher, m)`; запускает `go scheduler.Run(ctx, tick)`; монтирует `/metrics` (публично, вне `/api/v1`); graceful shutdown (`signal.NotifyContext` + `http.Server.Shutdown`).
- [ ] **Step 1: Реализовать wiring**
`main.go`: `ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)`; `m := metrics.New()`; `dispatcher := notify.NewDispatcher(st, cipher)`; `sched := scheduler.New(st, svc, dispatcher, m)`; `go sched.Run(ctx, time.Minute)`; собрать общий `http.ServeMux`/обёртку: `/metrics``m.Handler()`, `/api/...` → API-роутер (chi), `/` → web (порядок: metrics, api, web). Использовать `http.Server{Handler: root}` + `srv.Shutdown` при `ctx.Done()`. API `api.API{...}` дополнить `Schedule: st, Dispatch: dispatcher`. Инструментировать `service.Check/Apply` метриками (опционально здесь или через обёртку в scheduler — минимально: scheduler уже пишет ObserveCheck).
> Реализатор: аккуратно с приоритетом путей — `/metrics` и `/api/v1/*` перехватываются до web SPA-fallback (см. `isAPIPath` из Фазы 1C; добавь аналогичный `path=="/metrics"`).
- [ ] **Step 2: Проверки, коммит**
Run: `go build ./...`; `go vet ./...`; `go test ./... ` (Docker) — все зелёные; ручной smoke: `curl localhost:8080/metrics` содержит `dns_ar_*` (при локальном запуске).
```bash
git add cmd/server/main.go internal/api/api.go
git commit -m "feat(server): запуск планировщика, /metrics, graceful shutdown"
```
---
### Task 7: Frontend — клиент/типы/хуки (schedule, channels, history, статус)
**Files:**
- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`
- Create/Modify: `web/src/api/client.test.ts`
**Interfaces:**
- Типы: `Schedule{intervalSeconds, enabled}`, `Channel{id,type,config,enabled}`, `CreateChannelInput{type,config,secret}`, `CheckRun{id,createdAt,result}`; `Domain` дополнить `lastCheckStatus`.
- `api`: `getSchedule(pid)`, `putSchedule(pid, {intervalSeconds,enabled})`, `listChannels(pid)`, `createChannel(pid, input)`, `deleteChannel(pid, id)`, `testChannel(pid, id)`, `domainHistory(pid, did)`.
- Хуки: `useSchedule/useUpdateSchedule`, `useChannels/useCreateChannel/useDeleteChannel/useTestChannel`, `useDomainHistory(did)`.
- [ ] **Step 1: Падающий тест клиента**
`client.test.ts`: `api.putSchedule(pid,{...})` → PUT `/api/v1/projects/${pid}/schedule`; `api.createChannel(pid,{type,config,secret})` POST с secret в теле; `api.testChannel` POST `/channels/${id}/test`; `api.domainHistory(pid,did)` GET `/domains/${did}/history`. credentials:include.
- [ ] **Step 2: Реализовать client + types + hooks**
По образцу существующих (projectId первым аргументом; хуки берут project из useAuth; invalidateQueries). Секрет канала — только на вход.
- [ ] **Step 3: Тесты зелёные, коммит**
Run: `cd web && npm run test -- client`; `npx tsc --noEmit`.
```bash
git add web/src/api web/src/hooks
git commit -m "feat(web): API-клиент и хуки расписания/каналов/истории"
```
---
### Task 8: Frontend — UI (расписание, каналы, история, drift-badge)
**Files:**
- Create: `web/src/pages/SchedulePage.tsx` (или блок), `web/src/pages/ChannelsPage.tsx`, тесты
- Create: `web/src/components/StatusBadge.tsx`, `web/src/components/DomainHistory.tsx`
- Modify: `web/src/pages/DomainsPage.tsx` (drift-badge), `web/src/App.tsx` (роуты), `web/src/components/Layout.tsx` (навигация)
**Interfaces:**
- Consumes хуки из T7. Produces страницы/компоненты.
**Дизайн:** используй skill frontend-design (тёмная «technical console», консистентно). `StatusBadge`: in_sync=emerald, drift=amber, error=rose, unknown=muted.
- [ ] **Step 1: Падающие тесты**
- `StatusBadge.test`: рендерит правильный цвет/текст по статусу.
- `ChannelsPage.test`: список каналов (без секрета), создание (type=telegram → config{chat_id}+secret bot_token; type=webhook → config{url}) вызывает createChannel; удаление; тест-отправка вызывает testChannel; секрет не отображается.
- `SchedulePage.test`: показывает интервал/enabled, сохранение вызывает updateSchedule; валидация интервала.
- `DomainsPage`: рендерит StatusBadge по domain.lastCheckStatus (адаптировать существующий тест).
- [ ] **Step 2: Реализовать**
- `StatusBadge.tsx` — семантический бейдж (mono, цвет по статусу).
- `SchedulePage.tsx` — форма интервал (число секунд/выбор) + переключатель enabled → useUpdateSchedule.
- `ChannelsPage.tsx` — список + форма создания (переключение telegram/webhook меняет поля config/secret) + удаление + кнопка «Тест». Секрет type=password, только на вход.
- `DomainHistory.tsx` — список последних проверок домена (время + сводка) на DomainDiffPage или отдельно.
- `DomainsPage.tsx` — колонка StatusBadge (domain.lastCheckStatus).
- `App.tsx`/`Layout.tsx` — роуты `/schedule`, `/channels` + навигация (под ProtectedRoute).
- [ ] **Step 3: Тесты зелёные, сборка, коммит**
Run: `cd web && npm run test` (весь зелёный); `npx tsc --noEmit`; `npm run build`.
```bash
git add web/src/pages web/src/components web/src/App.tsx web/src/components/Layout.tsx
git commit -m "feat(web): расписание, каналы уведомлений, история проверок, drift-badge"
```
---
## Self-Review
- **Spec coverage:** миграция+store (T1), notify Telegram/Webhook+Dispatcher (T2), Prometheus metrics (T3), API schedule/channels/history (T5), scheduler in-process со сменой статуса+уведомлениями+метриками (T4), wiring+/metrics+graceful (T6), фронт клиент/хуки (T7), UI расписание/каналы/история/badge (T8). Все пункты spec-секции Фазы 3 покрыты. Планировщик не применяет (только check+notify). Уведомления идемпотентны по смене статуса.
- **Type consistency:** `store.Schedule/Channel`, `Domain.LastCheckStatus` едины (T1→T4→T5); `notify.Event/Notifier/Dispatcher` (T2→T4→T6); `metrics.Metrics` (T3→T4→T6); scheduler узкие интерфейсы SchedStore/Checker/NotifySender (T4); фронт `Schedule/Channel/CheckRun`, `api.*`, хуки (T7→T8). Статусы `unknown|in_sync|drift|error` едины backend/frontend.
- **Placeholders:** нет кода-плейсхолдера. Пометки «реализатор» — точки сверки sqlc-типов (pgtype) и настройки httptest-URL нотификаторов; образцы приведены. Политика уведомлений (shouldNotify) явно зафиксирована в T4.
## Проверка (end-to-end)
1. `go test ./... -v` (Docker) — все Go-пакеты зелёные (store/scheduler/notify/metrics/api).
2. `cd web && npm run test && npx tsc --noEmit && npm run build` — фронт зелёный.
3. `make web && go run ./cmd/server` (Postgres+env): войти; включить расписание (интервал 60с); добавить Telegram-канал (bot_token+chat_id) и Webhook; дождаться тика → при дрейфе домена приходит уведомление (смена статуса), повторный тик без изменений — молчит; `curl localhost:8080/metrics` содержит `dns_ar_checks_total`/`dns_ar_drift_domains`; drift-badge в списке доменов отражает статус; история проверок домена заполняется.
4. Проверить инварианты: секрет канала/bot_token не в ответах API и не в `/metrics`; планировщик ничего не применяет (зона не меняется без ручного apply).