Files
dns-autoresolver/docs/superpowers/plans/2026-07-05-check-status-and-diff-layout.md

12 KiB
Raw Permalink Blame History

Ручной check пишет статус + перенос длинных значений в diff — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use - [ ].

Goal: (A) Ручная проверка домена (Recheck / заход на страницу diff) должна обновлять last_check_status, а не оставлять unknown до прогона планировщика. (B) Длинные значения записей (DKIM-ключ, длинный SPF) должны переноситься в diff-представлении, не ломая вёрстку в горизонтальный оверфлоу.

Architecture: Статус домена (unknown|in_sync|drift|error) сейчас пишет только планировщик. Выносим вычисление статуса в internal/service (единый источник), и ручной check-хендлер тоже персистит его. Планировщик переиспользует те же константы (через алиасы — без переписывания). Фронтовый DiffView переносит длинные значения вместо распирания строки.

Tech Stack: Go (chi), React + Vite + Tailwind.

Global Constraints

  • Значения статуса — контракт с БД (domains.last_check_status, default unknown) и фронтом (StatusBadge): unknown|in_sync|drift|error. Единый источник — internal/service.
  • Ручной check НЕ шлёт уведомления (notify остаётся за планировщиком) — только обновляет статус и историю.
  • Планировщик остаётся владельцем notify; его поведение и тесты не должны сломаться.
  • Комментарии в Go — на английском; в web — как в окружающих файлах. TDD. НЕ коммитить реальную сборку internal/web/dist/*.

Task 1: Backend — ручной check персистит last_check_status

Files:

  • Create: internal/service/status.go, internal/service/status_test.go
  • Modify: internal/scheduler/scheduler.go (константы → алиасы service), internal/api/handlers.go (handleCheck пишет статус), internal/api/api.go (TenantStore += SetDomainStatus)
  • Test: internal/api/tenant_test.go / api_test.go (mock TenantStore += SetDomainStatus; тест что check пишет статус)

Interfaces:

  • Produces: service.StatusUnknown/StatusInSync/StatusDrift/StatusError; service.DeriveStatus(cs diff.Changeset) string.

  • Consumes: diff.Changeset.Actionable(); store.SetDomainStatus.

  • Step 1: Тест service.DeriveStatus

internal/service/status_test.go:

func TestDeriveStatus(t *testing.T) {
	// no actionable diffs → in_sync
	if got := service.DeriveStatus(diff.Changeset{}); got != service.StatusInSync {
		t.Fatalf("empty: %q", got)
	}
	// an actionable prune → drift
	cs := diff.Changeset{Diffs: []diff.RecordDiff{
		{Kind: diff.Delete, Type: model.A, Name: "x.example.com.", Actual: &model.Record{Type: model.A, Name: "x.example.com.", Values: []string{"1.1.1.1"}}},
	}}
	if got := service.DeriveStatus(cs); got != service.StatusDrift {
		t.Fatalf("prune: %q", got)
	}
}

(Сверь, что diff.Delete на managed-типе попадает в Actionable() — по существующим diff-тестам A-delete actionable, NS/SOA нет.) Run: go test ./internal/service/... -run DeriveStatus — Ожидание FAIL (нет функции).

  • Step 2: service/status.go
package service

import "github.com/vasyakrg/dns-autoresolver/internal/diff"

// Domain check statuses persisted in domains.last_check_status. "unknown" is
// the DB default for a domain that has never been checked. Single source of
// truth — the scheduler aliases these, the API check handler writes them.
const (
	StatusUnknown = "unknown"
	StatusInSync  = "in_sync"
	StatusDrift   = "drift"
	StatusError   = "error"
)

// DeriveStatus maps a successful check's changeset to a domain status:
// actionable drift (managed adds/updates/prunes) → "drift", otherwise
// "in_sync". A failed check (provider/loader error) is "error" and handled by
// the caller, which has the error in hand.
func DeriveStatus(cs diff.Changeset) string {
	if len(cs.Actionable()) > 0 {
		return StatusDrift
	}
	return StatusInSync
}

Run: go test ./internal/service/... -run DeriveStatus — Ожидание PASS.

  • Step 3: scheduler константы → алиасы (единый источник)

internal/scheduler/scheduler.go — заменить локальный блок const ( StatusUnknown = "unknown" ... ) на алиасы (добавить импорт internal/service):

const (
	StatusUnknown = service.StatusUnknown
	StatusInSync  = service.StatusInSync
	StatusDrift   = service.StatusDrift
	StatusError   = service.StatusError
)

Значения не меняются, весь остальной код планировщика и его тесты (используют StatusDrift и т.д.) работают без правок. Проверь, что нет цикла импорта (service НЕ импортирует scheduler — цикла нет). go build ./... должен пройти.

  • Step 4: TenantStore += SetDomainStatus

internal/api/api.go — в интерфейс TenantStore добавить:

	SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error

(*store.Store уже реализует — метод существует.) Обнови mock TenantStore в тестах API (добавь метод-заглушку с записью аргументов для проверки).

  • Step 5: handleCheck персистит статус

internal/api/handlers.go handleCheck — после получения changeset и до/после ответа записать статус (не фейлить ответ при ошибке записи статуса — она вторична):

cs, err := a.Svc.Check(r.Context(), pid, did)
if err != nil {
	// Persist the failure so the domain badge reflects it instead of stale
	// "unknown"; the write error (if any) is logged, never masks the 500.
	if serr := a.Store.SetDomainStatus(r.Context(), did, service.StatusError); serr != nil {
		log.Printf("api: set domain status (error) failed: %v", serr)
	}
	log.Printf("api: check failed: %v", err)
	writeErr(w, http.StatusInternalServerError, "internal error")
	return
}
if serr := a.Store.SetDomainStatus(r.Context(), did, service.DeriveStatus(cs)); serr != nil {
	log.Printf("api: set domain status failed: %v", serr)
}
writeJSON(w, http.StatusOK, toChangesetResponse(cs))

(импортируй internal/service.)

  • Step 6: Тест — check пишет статус

internal/api/…_test.go: mock CheckApplier возвращает changeset с actionable prune → после GET /domains/{did}/check mock TenantStore.SetDomainStatus получил "drift"; changeset без actionable → "in_sync"; Svc.Check возвращает ошибку → SetDomainStatus получил "error" и ответ 500.

  • Step 7: Прогон и коммит

Run: go build ./... && go test ./internal/.... Ожидание PASS.

git add internal/
git commit -m "fix(api): manual check persists last_check_status (was stale unknown)"

Task 2: Frontend — перенос длинных значений в diff и просмотре зоны

Files:

  • Modify: web/src/components/DiffView.tsx, web/src/pages/DomainDiffPage.tsx (таблица записей зоны)
  • Test: web/src/components/DiffView.test.tsx

Interfaces: только вёрстка; поведение не меняется.

Проблема: в DiffView RecordRowflex items-center, значения в <span className="... shrink-0 ..."> без переноса. Длинное значение (DKIM ~400 символов, длинный SPF) не переносится → распирает строку → горизонтальный скролл всей страницы.

  • Step 1: Перенос значений в RecordRow

Переструктурировать строку так, чтобы длинные значения переносились и НЕ вызывали горизонтального оверфлоу, сохранив аккуратный вид коротких записей. Рекомендуемый подход: сделать строку items-start, убрать shrink-0 у контейнера значений, дать значениям min-w-0 break-all (или break-words), при необходимости вынести пару actual→desired на отдельную строку под именем на узких экранах. Ключевые требования:

  • значения оборачиваются (break-all для длинных беспробельных строк вроде DKIM p=...);

  • контейнер строки не шире родителя (нет overflow-x у страницы) — проверить, что min-w-0 есть на всех flex-детях, которые могут содержать длинный контент;

  • badge типа и метка read-only остаются на месте, короткие значения читаемы. Реализуй по месту — это вёрстка, критерий приёмки в Step 3.

  • Step 2: Перенос в таблице записей зоны

web/src/pages/DomainDiffPage.tsx — read-only таблица записей зоны (ветка без шаблона): ячейка значений values.join("\n") с whitespace-pre-line. Добавить break-all (или break-words) и убедиться, что длинный DKIM в ячейке переносится, а таблица не растягивает страницу (обернуть таблицу в overflow-x-auto контейнер, если нужно горизонтальной прокрутки внутри самой таблицы, но НЕ страницы).

  • Step 3: Тест + прогон

Тест DiffView.test.tsx: запись с очень длинным значением (напр. DKIM "v=DKIM1; ... p="+"A".repeat(400)) рендерится (не падает) и значение присутствует; (визуальный критерий переноса словами проверяется вручную). Ключевая ручная проверка приёмки: на странице diff домена с DKIM/длинным SPF нет горизонтального скролла, значения переносятся. Run: cd web && npm run test -- --run && npx tsc --noEmit && npm run build.

cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "fix(web): wrap long record values in diff and zone view (no horizontal overflow)"

Итоговая проверка

  • go build ./... && go test ./... — PASS.
  • cd web && npm run test -- --run && npm run build — PASS.
  • Ручная: (A) Recheck домена с шаблоном и prune → бейдж в списке становится drift (не unknown); чистый домен → in_sync. (B) diff домена с DKIM/длинным SPF — значения переносятся, горизонтального скролла страницы нет.