Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
15 KiB
Просмотр зоны без шаблона + 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), но НЕ требует шаблон:
// 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:
// 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:
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
Метод:
// 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:
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}:
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
- Step 6: Прогон и коммит
Run: go build ./... && go test ./internal/.... Ожидание PASS.
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):
zoneRecords: (projectId: string, id: string) =>
req<RecordDTO[]>(projectPath(projectId, `/domains/${id}/records`)),
templateFromZone: (projectId: string, id: string) =>
req<Template>(projectPath(projectId, `/domains/${id}/template-from-zone`), { method: "POST" }),
useApi.ts — хуки useZoneRecords(id) (useQuery, enabled по id), useCreateTemplateFromZone() (useMutation; onSuccess invalidate ключей domains + zoneRecords + текущего домена, чтобы подтянулся templateId и появился дифф). Следуй существующему паттерну project-scoping (projectId из useAuth).
- Step 2: StatusBadge += «без шаблона»
StatusBadge.tsx: добавь тип-вариант no_template в STATUS_META:
no_template: { label: "без шаблона", color: "var(--diff-readonly)" },
и в resolveStatus разреши "no_template" как валидный; расширь CheckStatus. (Это UI-псевдостатус, не backend — приходит только с фронта.)
- Step 3: DomainsPage — убрать удаление, показать «без шаблона»
Убери импорт Trash2, useDeleteDomain, функцию onDelete, блок deleteDomain.isError, кнопку с Trash2. Столбец «Действия» оставляет только «Diff».
Статус: <StatusBadge status={d.templateId ? d.lastCheckStatus : "no_template"} /> — домен без привязанного шаблона показывает «без шаблона», а не малопонятный unknown.
- Step 4: DomainDiffPage — режим просмотра без шаблона
Определи наличие шаблона у домена: подтяни список useDomains() и найди domain = list.find(d => d.id === id) (список уже кэширован после DomainsPage; если пуст — грузится). hasTemplate = !!domain?.templateId.
- Если
hasTemplate→ текущий diff-flow (useCheckDomain), без изменений. - Если НЕ hasTemplate → НЕ вызывать check (убирает висящий «Вычисляю дифф»); показать:
- баннер: «Шаблон не привязан — дифф недоступен. Ниже текущие записи зоны.»
- кнопку «Создать шаблон из этой зоны» (
useCreateTemplateFromZone), после успеха — домен получит шаблон, инвалидация переключит на diff-режим; - read-only таблицу записей из
useZoneRecords(id)(колонки Тип/Имя/TTL/Значение; NS/SOA показываем тоже — это просмотр факта, помечать их read-only необязательно). При ошибке загрузки — понятный текст (не бесконечный лоадер).
Управляй ветвлением так, чтобы useCheckDomain не срабатывал без шаблона (enabled: hasTemplate) — иначе вернётся ошибка и повиснет лоадер. Если useCheckDomain(id) не принимает enabled — оберни вызов условием/добавь параметр.
- Step 5: Прогон и коммит
Run: cd web && npm run test -- --run && npx tsc --noEmit && npm run build. Ожидание PASS.
Тесты: (а) DomainsPage — нет кнопки удаления (Trash), домен без templateId показывает «без шаблона»; (б) StatusBadge — no_template рендерит «без шаблона»; (в) DomainDiffPage — домен без templateId рендерит записи зоны (мок useZoneRecords) + кнопку «Создать шаблон из этой зоны», check не вызывается.
cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "feat(web): view zone without template, snapshot button, no-template status, drop delete"
Итоговая проверка
go build ./... && go test ./...— PASS.cd web && npm run test -- --run && npm run build— PASS.- Ручная: домен без шаблона → открывается, видны записи зоны, статус «без шаблона», кнопка «Создать шаблон из зоны» создаёт шаблон (без NS/SOA) и привязывает → страница переключается на дифф; кнопки удаления домена нет.