1540140542
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
241 lines
15 KiB
Markdown
241 lines
15 KiB
Markdown
# Просмотр зоны без шаблона + 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) и привязывает → страница переключается на дифф; кнопки удаления домена нет.
|