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

177 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ручной 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`:
```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**
```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`):
```go
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` добавить:
```go
SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error
```
(`*store.Store` уже реализует — метод существует.) Обнови mock `TenantStore` в тестах API (добавь метод-заглушку с записью аргументов для проверки).
- [ ] **Step 5: handleCheck персистит статус**
`internal/api/handlers.go` `handleCheck` — после получения changeset и до/после ответа записать статус (не фейлить ответ при ошибке записи статуса — она вторична):
```go
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.
```bash
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` `RecordRow``flex 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`.
```bash
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 — значения переносятся, горизонтального скролла страницы нет.