Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 KiB
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 APIsendMessage,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— setlast_run_atCreateChannel(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 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 ./....
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 defaulthttps://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.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:
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) *Schedulerfunc (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/обёртку: /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_* (при локальном запуске).
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)
go test ./... -v(Docker) — все Go-пакеты зелёные (store/scheduler/notify/metrics/api).cd web && npm run test && npx tsc --noEmit && npm run build— фронт зелёный.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 в списке доменов отражает статус; история проверок домена заполняется.- Проверить инварианты: секрет канала/bot_token не в ответах API и не в
/metrics; планировщик ничего не применяет (зона не меняется без ручного apply).