Files
dns-autoresolver/docs/superpowers/plans/2026-07-05-zone-view-snapshot.md
2026-07-05 11:48:21 +07:00

15 KiB
Raw Permalink Blame History

Просмотр зоны без шаблона + 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}/recordsRecordDTO[]; POST /domains/{did}/template-from-zoneTemplate.

  • Produces: read-only просмотр зоны при отсутствии шаблона; статус-бейдж «без шаблона»; список доменов без кнопки удаления.

  • Step 1: types + client + hooks

types.tsRecordDTO уже есть (сверь: {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) и привязывает → страница переключается на дифф; кнопки удаления домена нет.