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

241 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Просмотр зоны без шаблона + 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<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`:
```ts
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 не вызывается.
```bash
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) и привязывает → страница переключается на дифф; кнопки удаления домена нет.