diff --git a/docs/superpowers/plans/2026-07-05-zone-view-snapshot.md b/docs/superpowers/plans/2026-07-05-zone-view-snapshot.md new file mode 100644 index 0000000..5c13cab --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-zone-view-snapshot.md @@ -0,0 +1,240 @@ +# Просмотр зоны без шаблона + snapshot-шаблон Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Дать видеть содержимое зоны без привязанного шаблона, создавать шаблон-эталон из текущего состояния зоны одним действием, показывать понятный статус «без шаблона» и убрать пугающую кнопку удаления домена. + +**Architecture:** Провайдер (Selectel) — источник фактического состояния зоны. Сейчас весь просмотр завязан на дифф, а `LoadDomain` требует шаблон, поэтому зону без шаблона нельзя ни увидеть, ни проверить (вечный `unknown`). Добавляем read-only чтение записей зоны (без шаблона) и снимок-в-шаблон: читаем текущие записи, фильтруем управляемые (без NS/SOA), создаём шаблон и привязываем к домену. + +**Tech Stack:** Go (chi, provider registry), React 19 + Vite + TanStack Query. + +## Global Constraints + +- Провайдер = источник правды для фактического состояния зоны. Планировщик остаётся read-only (не применяет). +- Snapshot включает только управляемые типы (`model.RecordType.Managed()` = A/AAAA/CNAME/MX/TXT/SRV); NS/SOA исключаются (read-only, не в шаблоне). +- Multi-tenancy/IDOR: все новые endpoint'ы скоупятся по projectID из контекста; загрузка домена/зоны — по (domainID, projectID), как в существующем LoadDomain. +- Кнопку удаления домена убрать только из UI; backend DeleteDomain/роут не трогаем в этом заходе (git-like push/pull — отдельная фаза). +- Комментарии в Go — на английском; в web — как в окружающих файлах. +- TDD. НЕ коммитить реальную сборку `internal/web/dist/*` (перед коммитом `git checkout internal/web/dist/index.html`). + +--- + +### Task 1: Backend — чтение записей зоны без шаблона + snapshot-в-шаблон + +**Files:** +- Modify: `internal/store/loader.go` (LoadZone), `internal/service/service.go` (ZoneRef, Loader iface, ZoneRecords), `internal/api/handlers.go` (handleZoneRecords, handleTemplateFromZone), `internal/api/api.go` (роуты + интерфейсы Service/Store), `internal/api/*_dto.go` (если нужен ответ) +- Test: `internal/service/service_test.go`, `internal/api/*_test.go`, `internal/store/*_test.go` + +**Interfaces:** +- Produces: `service.ZoneRef{ZoneID,Provider,SecretEnc}`; `Loader.LoadZone(ctx,pid,did) (ZoneRef,error)`; `DomainService.ZoneRecords(ctx,pid,did) ([]model.Record,error)`; `GET /domains/{did}/records`; `POST /domains/{did}/template-from-zone`. +- Consumes: `provider.GetRecords`; `Store.CreateTemplate`, `Store.SetDomainTemplate`, `Store.GetDomain`; `model.RecordType.Managed()`; `dto.FromModel`. + +- [ ] **Step 1: Тест — LoadZone возвращает провайдера/секрет/zoneID без требования шаблона** + +`internal/store/…` (или через service-тест с фейком): домен без template_id → `LoadZone` возвращает ZoneRef (не ошибку «no template»). Отличие от `LoadDomain`, который на `Doc==nil` возвращает ошибку. Run соответствующий тест — Ожидание FAIL (метода нет). + +- [ ] **Step 2: store.LoadZone** + +`internal/store/loader.go` — переиспользует тот же `LoadDomainFull` (он LEFT JOIN, Doc может быть nil), но НЕ требует шаблон: +```go +// LoadZone returns just the provider-access half of a domain (provider name, +// encrypted secret, zone id), WITHOUT requiring an attached template — so a +// zone's live records can be read for viewing/snapshot even when no template +// is set. Scoped by projectID (same IDOR closure as LoadDomain). +func (s *Store) LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (service.ZoneRef, error) { + row, err := s.q.LoadDomainFull(ctx, db.LoadDomainFullParams{ID: domainID, ProjectID: projectID}) + if err != nil { + return service.ZoneRef{}, err + } + return service.ZoneRef{ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc}, nil +} +``` + +- [ ] **Step 3: service.ZoneRef + Loader iface + ZoneRecords** + +`internal/service/service.go`: +```go +// ZoneRef is the provider-access subset of a domain, without a template — +// enough to read a zone's live records. +type ZoneRef struct { + ZoneID string + Provider string + SecretEnc string +} +``` +Расширь интерфейс `Loader`: +```go + LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error) +``` +Метод: +```go +// ZoneRecords reads a zone's current records straight from the provider, +// with no diff and no template required. Used for read-only zone viewing and +// as the source for a snapshot template. +func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) { + ref, err := s.loader.LoadZone(ctx, projectID, domainID) + if err != nil { + return nil, err + } + p, err := s.reg.ByName(ref.Provider) + if err != nil { + return nil, err + } + secret, err := s.cipher.Decrypt(ref.SecretEnc) + if err != nil { + return nil, err + } + return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID) +} +``` +(Импортируй `model`.) Обнови все фейки Loader в тестах (service_test.go, api tenant_test mockLoader если есть) под новый метод LoadZone. + +- [ ] **Step 4: Тесты API — records + template-from-zone** + +`internal/api/…_test.go`: (а) `GET /domains/{did}/records` → 200 + список записей зоны (мок Service.ZoneRecords); (б) `POST /domains/{did}/template-from-zone` → создаёт шаблон только из управляемых записей (в моке ZoneRecords верни A+TXT+NS+SOA → в CreateTemplate ушли ТОЛЬКО A+TXT), затем SetDomainTemplate вызван с id нового шаблона; 201. Run: Ожидание FAIL. + +- [ ] **Step 5: API-хендлеры + роуты** + +Определи в `internal/api/api.go` интерфейсе `Service` метод `ZoneRecords(ctx, projectID, domainID uuid.UUID) ([]model.Record, error)` (сверь, как назван service-порт в api — используй существующий паттерн; если API держит `*service.DomainService` напрямую — вызывай метод). Store-интерфейс уже содержит CreateTemplate/SetDomainTemplate/GetDomain (сверь имена/сигнатуры). + +`internal/api/handlers.go`: +```go +func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) { + pid, _ := projectIDFrom(r.Context()) + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) + if err != nil { + log.Printf("api: zone records failed: %v", err) + writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + return + } + doc := dto.FromModel(recs) + writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO +} + +func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) { + pid, _ := projectIDFrom(r.Context()) + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + dom, err := a.Store.GetDomain(r.Context(), did, pid) + if err != nil { + log.Printf("api: template-from-zone: get domain failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) + if err != nil { + log.Printf("api: template-from-zone: zone records failed: %v", err) + writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + return + } + // Snapshot only managed records — NS/SOA are read-only and never templated. + managed := make([]model.Record, 0, len(recs)) + for _, rc := range recs { + if rc.Type.Managed() { + managed = append(managed, rc) + } + } + doc := dto.FromModel(managed) + tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc) + if err != nil { + log.Printf("api: template-from-zone: create template failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil { + log.Printf("api: template-from-zone: attach template failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl)) +} +``` +(Сверь: как API вызывает сервис — поле `a.Svc`/`a.Service`; тип `tmpl.ID`; наличие `toTemplateResponse`. Используй существующие имена. `model` импортируй.) + +Роуты в `api.go` внутри `/domains/{did}`: +```go +r.Get("/records", a.handleZoneRecords) +r.Post("/template-from-zone", a.handleTemplateFromZone) +``` + +- [ ] **Step 6: Прогон и коммит** + +Run: `go build ./... && go test ./internal/...`. Ожидание PASS. +```bash +git add internal/ +git commit -m "feat(api): read zone records without template + snapshot-to-template" +``` + +--- + +### Task 2: Frontend — просмотр зоны без шаблона, snapshot-кнопка, статус, убрать удаление + +**Files:** +- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`, `web/src/components/StatusBadge.tsx`, `web/src/pages/DomainsPage.tsx`, `web/src/pages/DomainDiffPage.tsx` +- Test: `web/src/pages/DomainsPage.test.tsx`, `web/src/pages/DomainDiffPage.test.tsx`, `web/src/components/StatusBadge.test.tsx` + +**Interfaces:** +- Consumes: `GET /domains/{did}/records` → `RecordDTO[]`; `POST /domains/{did}/template-from-zone` → `Template`. +- Produces: read-only просмотр зоны при отсутствии шаблона; статус-бейдж «без шаблона»; список доменов без кнопки удаления. + +- [ ] **Step 1: types + client + hooks** + +`types.ts` — `RecordDTO` уже есть (сверь: `{type,name,ttl,values}`). Добавь методы клиента (`client.ts`): +```ts +zoneRecords: (projectId: string, id: string) => + req(projectPath(projectId, `/domains/${id}/records`)), +templateFromZone: (projectId: string, id: string) => + req