docs: plan for manual-check status + diff value wrapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
# Ручной 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 — значения переносятся, горизонтального скролла страницы нет.
|
||||||
Reference in New Issue
Block a user