# Просмотр зоны без шаблона + 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