diff --git a/docs/superpowers/plans/2026-07-04-phase3-scheduler-notify-metrics.md b/docs/superpowers/plans/2026-07-04-phase3-scheduler-notify-metrics.md new file mode 100644 index 0000000..d50c120 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-phase3-scheduler-notify-metrics.md @@ -0,0 +1,499 @@ +# 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).