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

33 KiB
Raw Blame History

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())>0drift, иначе in_sync; ошибка service.Checkerror.
  • Каналы: 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:

-- +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:

-- 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:

-- 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 (добавить):

-- 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 generateinternal/store/db/ обновлён; go build ./internal/store/db/.

  • Step 4: Store-обёртки

Добавить в internal/store/tenant.go типы Schedule/Channel и методы из Interfaces. configjson.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 ./....

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:

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:

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 ./....

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.Handlerpromhttp.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:

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 ./....

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):

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 ./....

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 ./....

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/обёртку: /metricsm.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_* (при локальном запуске).

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.

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.

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).