Merge feature/zone-view-snapshot: просмотр зоны без шаблона + snapshot-шаблон
Просмотр записей зоны без привязанного шаблона (read-only), создание шаблона-снимка из текущего состояния зоны (managed-only, авто-привязка), статус «без шаблона» вместо unknown, убрана кнопка удаления домена. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
# Просмотр зоны без шаблона + 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) и привязывает → страница переключается на дифф; кнопки удаления домена нет.
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||
@@ -20,6 +21,10 @@ import (
|
||||
type CheckApplier interface {
|
||||
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
|
||||
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
|
||||
// ZoneRecords reads a zone's current records straight from the provider,
|
||||
// with no diff and no template required — backs read-only zone viewing
|
||||
// and the template-from-zone snapshot.
|
||||
ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error)
|
||||
}
|
||||
|
||||
// TenantStore is the narrow persistence surface the CRUD handlers depend on.
|
||||
@@ -39,6 +44,9 @@ type TenantStore interface {
|
||||
|
||||
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
|
||||
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
|
||||
// GetDomain is used by the template-from-zone snapshot to read the
|
||||
// domain's zone name (for the generated template's name) before creating it.
|
||||
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
||||
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
|
||||
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
|
||||
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
|
||||
@@ -117,6 +125,8 @@ func NewRouter(a *API) http.Handler {
|
||||
r.Patch("/", a.handleSetDomainTemplate)
|
||||
r.Delete("/", a.handleDeleteDomain)
|
||||
r.Get("/history", a.handleDomainHistory)
|
||||
r.Get("/records", a.handleZoneRecords)
|
||||
r.Post("/template-from-zone", a.handleTemplateFromZone)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
|
||||
type mockCheckApplier struct {
|
||||
lastReq service.ApplyRequest
|
||||
zoneRecords []model.Record
|
||||
zoneErr error
|
||||
}
|
||||
|
||||
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
|
||||
@@ -28,6 +30,12 @@ func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.
|
||||
m.lastReq = req
|
||||
return diff.Changeset{}, nil
|
||||
}
|
||||
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
||||
if m.zoneErr != nil {
|
||||
return nil, m.zoneErr
|
||||
}
|
||||
return m.zoneRecords, nil
|
||||
}
|
||||
|
||||
// newTestAPI wires a fixed authenticated user who owns whatever project id
|
||||
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
@@ -70,3 +72,86 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
||||
}
|
||||
|
||||
// handleZoneRecords reads a zone's current records straight from the
|
||||
// provider — no template required, no diff computed. Backs read-only zone
|
||||
// viewing for domains that don't have a template attached (yet).
|
||||
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)
|
||||
if errors.Is(err, service.ErrProviderUnavailable) {
|
||||
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
|
||||
} else {
|
||||
writeErr(w, http.StatusNotFound, "домен не найден")
|
||||
}
|
||||
return
|
||||
}
|
||||
doc := dto.FromModel(recs)
|
||||
writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO
|
||||
}
|
||||
|
||||
// handleTemplateFromZone snapshots a zone's current managed records (NS/SOA
|
||||
// excluded — they're read-only, never part of a template) into a brand new
|
||||
// template, then auto-attaches it to the domain so check/apply become
|
||||
// available immediately.
|
||||
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.StatusNotFound, "домен не найден")
|
||||
return
|
||||
}
|
||||
// This endpoint only makes sense for a domain with no template attached
|
||||
// yet — it snapshots the zone's live state into a brand-new template.
|
||||
// If a template is already bound, re-attaching a fresh snapshot would
|
||||
// silently orphan the existing one; re-pointing a domain to a different
|
||||
// template is a separate, explicit action and must not happen as a side
|
||||
// effect of a retried/duplicate POST here.
|
||||
if dom.TemplateID != nil {
|
||||
writeErr(w, http.StatusConflict, "шаблон уже привязан")
|
||||
return
|
||||
}
|
||||
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
|
||||
if err != nil {
|
||||
log.Printf("api: template-from-zone: zone records failed: %v", err)
|
||||
if errors.Is(err, service.ErrProviderUnavailable) {
|
||||
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
|
||||
} else {
|
||||
writeErr(w, http.StatusNotFound, "домен не найден")
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||
)
|
||||
@@ -97,6 +98,9 @@ func (r *recordingCheckApplier) Apply(context.Context, uuid.UUID, uuid.UUID, ser
|
||||
r.applyCalled = true
|
||||
return diff.Changeset{}, nil
|
||||
}
|
||||
func (r *recordingCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- RequireAuth ---
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||
)
|
||||
@@ -98,6 +100,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
|
||||
return m.domains, nil
|
||||
}
|
||||
|
||||
func (m *mockTenantStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) {
|
||||
for _, d := range m.domains {
|
||||
if d.ID == id {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return store.Domain{}, errors.New("domain not found")
|
||||
}
|
||||
|
||||
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
||||
|
||||
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
|
||||
@@ -599,3 +610,149 @@ func TestDeleteDomain_BadUUID(t *testing.T) {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- zone view / template-from-zone (Task 1: no-template zone snapshot) ---
|
||||
|
||||
// TestZoneRecords_ReturnsProviderRecords covers the read-only zone-viewing
|
||||
// endpoint: it returns whatever the service reads straight from the
|
||||
// provider, with no template involved at all.
|
||||
func TestZoneRecords_ReturnsProviderRecords(t *testing.T) {
|
||||
a, ts := newTenantTestAPI()
|
||||
domID := uuid.New()
|
||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
}}
|
||||
router := NewRouter(a)
|
||||
|
||||
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []dto.RecordDTO
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0].Type != "A" {
|
||||
t.Fatalf("unexpected records response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
|
||||
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from
|
||||
// the generated template, and the new template must be auto-attached to the
|
||||
// domain (SetDomainTemplate) so check/apply become immediately available.
|
||||
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
|
||||
a, ts := newTenantTestAPI()
|
||||
domID := uuid.New()
|
||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 -all"}},
|
||||
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
||||
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
|
||||
}}
|
||||
router := NewRouter(a)
|
||||
|
||||
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ts.createTemplate == nil {
|
||||
t.Fatal("expected CreateTemplate to be called")
|
||||
}
|
||||
if len(ts.createTemplate.Doc.Records) != 2 {
|
||||
t.Fatalf("expected only the 2 managed records (A+TXT) in the snapshot, got %+v", ts.createTemplate.Doc.Records)
|
||||
}
|
||||
for _, r := range ts.createTemplate.Doc.Records {
|
||||
if r.Type == "NS" || r.Type == "SOA" {
|
||||
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
|
||||
}
|
||||
}
|
||||
// SetDomainTemplate must have been called with the newly created template's id.
|
||||
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
|
||||
t.Fatalf("expected domain auto-attached to new template %s, got %+v", ts.createTemplate.ID, ts.domains[0].TemplateID)
|
||||
}
|
||||
|
||||
var resp templateResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.ID != ts.createTemplate.ID.String() || len(resp.Records) != 2 {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateFromZone_AlreadyAttachedReturns409 covers the guard against
|
||||
// re-snapshotting a domain that already has a template bound: a direct
|
||||
// POST (e.g. curl or a client retry) must not silently create a new
|
||||
// template and re-point the domain, orphaning the previously attached one.
|
||||
func TestTemplateFromZone_AlreadyAttachedReturns409(t *testing.T) {
|
||||
a, ts := newTenantTestAPI()
|
||||
domID := uuid.New()
|
||||
existingTemplateID := uuid.New()
|
||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1", TemplateID: &existingTemplateID}}
|
||||
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
}}
|
||||
router := NewRouter(a)
|
||||
|
||||
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ts.createTemplate != nil {
|
||||
t.Fatalf("expected CreateTemplate NOT to be called, got %+v", ts.createTemplate)
|
||||
}
|
||||
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != existingTemplateID {
|
||||
t.Fatalf("expected existing template binding untouched, got %+v", ts.domains[0].TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
|
||||
// an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself
|
||||
// failed) must surface as 502 (bad gateway), not a generic 500 or 404.
|
||||
func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
|
||||
a, ts := newTenantTestAPI()
|
||||
domID := uuid.New()
|
||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||
a.Svc = &mockCheckApplier{zoneErr: fmt.Errorf("%w: boom", service.ErrProviderUnavailable)}
|
||||
router := NewRouter(a)
|
||||
|
||||
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneRecords_NotFoundReturns404 covers the domain-resolution-failure
|
||||
// path: an error that does NOT wrap service.ErrProviderUnavailable (e.g. the
|
||||
// domain doesn't exist in this project) must surface as 404, not 502 — the
|
||||
// provider was never even reached.
|
||||
func TestZoneRecords_NotFoundReturns404(t *testing.T) {
|
||||
a, ts := newTenantTestAPI()
|
||||
domID := uuid.New()
|
||||
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||
a.Svc = &mockCheckApplier{zoneErr: errors.New("not found")}
|
||||
router := NewRouter(a)
|
||||
|
||||
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,26 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||
)
|
||||
|
||||
// ErrProviderUnavailable marks failures that happened while talking to the
|
||||
// DNS provider itself (network, auth, rate limit, ...), as opposed to
|
||||
// failures resolving the domain/zone locally (not found, bad credentials
|
||||
// stored, unknown provider name). Callers use errors.Is against this to
|
||||
// pick 502 vs 404 without leaking provider error details as "not found".
|
||||
var ErrProviderUnavailable = errors.New("service: provider unavailable")
|
||||
|
||||
// DomainRef is the minimal data the service needs about a domain.
|
||||
type DomainRef struct {
|
||||
ZoneID string
|
||||
@@ -20,8 +30,17 @@ type DomainRef struct {
|
||||
Template dto.TemplateDoc
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type Loader interface {
|
||||
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
|
||||
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
|
||||
}
|
||||
|
||||
type Recorder interface {
|
||||
@@ -81,6 +100,32 @@ func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
recs, err := p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
|
||||
if err != nil {
|
||||
// Only a failure of the provider call itself is "provider unavailable" —
|
||||
// LoadZone/ByName/Decrypt errors above are local resolution failures
|
||||
// (e.g. domain not found) and must not be conflated with it.
|
||||
return nil, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
||||
}
|
||||
return recs, nil
|
||||
}
|
||||
|
||||
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
|
||||
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
||||
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
||||
|
||||
@@ -49,6 +49,13 @@ func (l fakeLoader) LoadDomain(context.Context, uuid.UUID, uuid.UUID) (DomainRef
|
||||
return l.ref, nil
|
||||
}
|
||||
|
||||
// LoadZone mirrors LoadDomain's provider-access fields but — unlike
|
||||
// LoadDomain — never errors on a missing template, matching the real
|
||||
// store.LoadZone contract.
|
||||
func (l fakeLoader) LoadZone(context.Context, uuid.UUID, uuid.UUID) (ZoneRef, error) {
|
||||
return ZoneRef{ZoneID: l.ref.ZoneID, Provider: l.ref.Provider, SecretEnc: l.ref.SecretEnc}, nil
|
||||
}
|
||||
|
||||
type nopRecorder struct{}
|
||||
|
||||
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
|
||||
@@ -78,6 +85,24 @@ func TestCheckProducesDiff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
|
||||
// path: ZoneRecords must return the provider's live records with no diff
|
||||
// and no template involved (loader's Template field is left zero-valued).
|
||||
func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
|
||||
actual := []model.Record{
|
||||
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
||||
}
|
||||
svc, _ := setup(t, actual, dto.TemplateDoc{})
|
||||
recs, err := svc.ZoneRecords(context.Background(), uuid.New(), uuid.New())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("expected 2 records straight from the provider, got %+v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRespectsPruneGuard(t *testing.T) {
|
||||
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
|
||||
actual := []model.Record{
|
||||
|
||||
@@ -33,6 +33,18 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
|
||||
}, 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
|
||||
}
|
||||
|
||||
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
|
||||
// as a check_runs row.
|
||||
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
|
||||
|
||||
@@ -91,3 +91,35 @@ func TestLoadDomainNoTemplate(t *testing.T) {
|
||||
t.Fatal("expected error for domain without template, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadZoneNoTemplate covers the Task 1 gap: a domain with no attached
|
||||
// template must still be resolvable to its provider-access ref (ZoneRef) so
|
||||
// its live records can be read/snapshotted — unlike LoadDomain, LoadZone
|
||||
// must NOT error out on a nil template doc.
|
||||
func TestLoadZoneNoTemplate(t *testing.T) {
|
||||
s, ctx := newStore(t)
|
||||
|
||||
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||
ID: uuid.New(), ProjectID: defaultProject,
|
||||
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
|
||||
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
|
||||
ZoneName: "example.com", ZoneID: "zone-3", TemplateID: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ref, err := s.LoadZone(ctx, defaultProject, domain.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("expected LoadZone to succeed without a template, got error: %v", err)
|
||||
}
|
||||
if ref.ZoneID != "zone-3" || ref.Provider != "selectel" || ref.SecretEnc != "enc-blob" {
|
||||
t.Fatalf("zone ref mismatch: %+v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
AuthState,
|
||||
Account, CreateAccountInput, Template, CreateTemplateInput,
|
||||
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
||||
Schedule, Channel, CreateChannelInput, CheckRun,
|
||||
Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
|
||||
} from "./types"
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
@@ -74,6 +74,11 @@ export const api = {
|
||||
setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
|
||||
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
|
||||
|
||||
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" }),
|
||||
|
||||
checkDomain: (projectId: string, id: string) =>
|
||||
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
|
||||
applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
|
||||
|
||||
@@ -28,6 +28,12 @@ test("unknown — muted, текст «unknown»", () => {
|
||||
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
|
||||
})
|
||||
|
||||
test("no_template — muted, текст «без шаблона»", () => {
|
||||
render(<StatusBadge status="no_template" />)
|
||||
expect(screen.getByText("без шаблона")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
|
||||
})
|
||||
|
||||
test("отсутствие статуса трактуется как unknown", () => {
|
||||
render(<StatusBadge />)
|
||||
expect(screen.getByText("unknown")).toBeInTheDocument()
|
||||
|
||||
@@ -5,17 +5,20 @@ import { cn } from "@/lib/utils"
|
||||
// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already
|
||||
// established for the domain-diff console so a drifted zone reads the same
|
||||
// "amber" whether you're looking at the list or the diff view.
|
||||
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error"
|
||||
// no_template is a frontend-only pseudo-status (backend never sends it) —
|
||||
// shown when a domain has no template attached, so there's nothing to diff.
|
||||
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" | "no_template"
|
||||
|
||||
const STATUS_META: Record<CheckStatus, { label: string; color: string }> = {
|
||||
in_sync: { label: "in sync", color: "var(--diff-add)" },
|
||||
drift: { label: "drift", color: "var(--diff-update)" },
|
||||
error: { label: "error", color: "var(--diff-delete)" },
|
||||
unknown: { label: "unknown", color: "var(--diff-readonly)" },
|
||||
no_template: { label: "без шаблона", color: "var(--diff-readonly)" },
|
||||
}
|
||||
|
||||
function resolveStatus(status?: string): CheckStatus {
|
||||
if (status === "in_sync" || status === "drift" || status === "error") return status
|
||||
if (status === "in_sync" || status === "drift" || status === "error" || status === "no_template") return status
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
||||
+23
-11
@@ -111,23 +111,35 @@ export function useSetDomainTemplate() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||
})
|
||||
}
|
||||
export function useDeleteDomain() {
|
||||
export function useCheckDomain(id: string, enabled = true) {
|
||||
const { project } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["check", project?.id, id],
|
||||
queryFn: () => api.checkDomain(project!.id, id),
|
||||
enabled: !!project && !!id && enabled,
|
||||
})
|
||||
}
|
||||
export function useZoneRecords(id: string, enabled = true) {
|
||||
const { project } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["zoneRecords", project?.id, id],
|
||||
queryFn: () => api.zoneRecords(project!.id, id),
|
||||
enabled: !!project && !!id && enabled,
|
||||
})
|
||||
}
|
||||
export function useCreateTemplateFromZone() {
|
||||
const { project } = useAuth()
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
const pid = requireProjectId(project)
|
||||
return api.deleteDomain(pid, id)
|
||||
return api.templateFromZone(pid, id)
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
|
||||
qc.invalidateQueries({ queryKey: ["zoneRecords", project?.id, id] })
|
||||
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||
})
|
||||
}
|
||||
export function useCheckDomain(id: string) {
|
||||
const { project } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["check", project?.id, id],
|
||||
queryFn: () => api.checkDomain(project!.id, id),
|
||||
enabled: !!project && !!id,
|
||||
})
|
||||
}
|
||||
export function useApplyDomain(id: string) {
|
||||
|
||||
@@ -5,12 +5,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainDiffPage } from "./DomainDiffPage"
|
||||
import { AuthProvider } from "@/auth/AuthContext"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach } from "vitest"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Domain } from "@/api/types"
|
||||
|
||||
const PROJECT_ID = "p1"
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
const domainWithTemplate: Domain = {
|
||||
id: "d1",
|
||||
providerAccountId: "acc1",
|
||||
zoneName: "example.com.",
|
||||
zoneId: "z1",
|
||||
templateId: "t1",
|
||||
lastCheckStatus: "drift",
|
||||
}
|
||||
|
||||
function renderPage(qc: QueryClient = new QueryClient()) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthProvider>
|
||||
@@ -29,6 +38,7 @@ beforeEach(() => {
|
||||
project: { id: PROJECT_ID, name: "Default" },
|
||||
})
|
||||
vi.spyOn(api, "domainHistory").mockResolvedValue([])
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
||||
})
|
||||
|
||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
||||
@@ -38,6 +48,7 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
||||
readOnly: [], inSyncCount: 0,
|
||||
})
|
||||
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
@@ -52,4 +63,82 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
||||
|
||||
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
|
||||
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
|
||||
let resolveListDomains: (domains: Domain[]) => void
|
||||
vi.spyOn(api, "listDomains").mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveListDomains = resolve
|
||||
}),
|
||||
)
|
||||
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||
updates: [], prunes: [], readOnly: [], inSyncCount: 0,
|
||||
})
|
||||
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/загрузка/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/шаблон не привязан/i)).not.toBeInTheDocument()
|
||||
expect(checkSpy).not.toHaveBeenCalled()
|
||||
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
||||
|
||||
resolveListDomains!([domainWithTemplate])
|
||||
|
||||
expect(await screen.findByRole("button", { name: /apply/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("домен без шаблона показывает записи зоны и не вызывает check", async () => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue([
|
||||
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||
])
|
||||
const checkSpy = vi.spyOn(api, "checkDomain")
|
||||
vi.spyOn(api, "zoneRecords").mockResolvedValue([
|
||||
{ type: "A", name: "example.com.", ttl: 3600, values: ["1.2.3.4"] },
|
||||
])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/шаблон не привязан/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /создать шаблон из этой зоны/i })).toBeInTheDocument()
|
||||
expect(await screen.findByText("example.com.")).toBeInTheDocument()
|
||||
expect(screen.getByText("1.2.3.4")).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText(/вычисляю дифф/i)).not.toBeInTheDocument()
|
||||
expect(checkSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("ошибка загрузки списка доменов показывает баннер ошибки и не уходит в ветку без шаблона", async () => {
|
||||
vi.spyOn(api, "listDomains").mockRejectedValue(new Error("network down"))
|
||||
const checkSpy = vi.spyOn(api, "checkDomain")
|
||||
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
||||
// retry:false — иначе react-query переретраит listDomains с экспоненциальной
|
||||
// задержкой и findByText не успевает дождаться финального isError.
|
||||
renderPage(new QueryClient({ defaultOptions: { queries: { retry: false } } }))
|
||||
|
||||
expect(await screen.findByText(/не удалось загрузить список доменов/i)).toBeInTheDocument()
|
||||
expect(screen.getByText("network down")).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText(/шаблон не привязан/i)).not.toBeInTheDocument()
|
||||
expect(checkSpy).not.toHaveBeenCalled()
|
||||
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("создание шаблона из зоны вызывает templateFromZone", async () => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue([
|
||||
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||
])
|
||||
vi.spyOn(api, "zoneRecords").mockResolvedValue([])
|
||||
const templateFromZoneSpy = vi.spyOn(api, "templateFromZone").mockResolvedValue({
|
||||
id: "t9", name: "example.com. snapshot", records: [], version: 1,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const createBtn = await screen.findByRole("button", { name: /создать шаблон из этой зоны/i })
|
||||
await user.click(createBtn)
|
||||
|
||||
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
|
||||
})
|
||||
|
||||
@@ -6,13 +6,37 @@ import { DomainHistory } from "@/components/DomainHistory"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
useApplyDomain,
|
||||
useCheckDomain,
|
||||
useCreateTemplateFromZone,
|
||||
useDomains,
|
||||
useZoneRecords,
|
||||
} from "@/hooks/useApi"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function DomainDiffPage() {
|
||||
const { id = "" } = useParams()
|
||||
const check = useCheckDomain(id)
|
||||
const domains = useDomains()
|
||||
const domain = domains.data?.find((d) => d.id === id)
|
||||
const hasTemplate = !!domain?.templateId
|
||||
|
||||
const check = useCheckDomain(id, hasTemplate)
|
||||
const apply = useApplyDomain(id)
|
||||
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
||||
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
||||
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
||||
// (успешный ответ), что шаблона нет.
|
||||
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
||||
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||
const pruneCheckboxId = useId()
|
||||
|
||||
@@ -20,11 +44,46 @@ export function DomainDiffPage() {
|
||||
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
||||
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
||||
const pruneWarning = applyPrunes && hasPrunes
|
||||
const recordList = zoneRecords.data ?? []
|
||||
|
||||
function onApply() {
|
||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
||||
}
|
||||
|
||||
function onCreateTemplateFromZone() {
|
||||
createTemplateFromZone.mutate(id)
|
||||
}
|
||||
|
||||
if (domains.isPending) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
Загрузка…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Список доменов не загрузился — hasTemplate тут недостоверно (domain
|
||||
// === undefined из-за domains.data === undefined даёт hasTemplate=false),
|
||||
// поэтому без этой проверки страница молча уходит в ветку «без шаблона»
|
||||
// и дёргает zoneRecords для несуществующего состояния. Показываем ошибку
|
||||
// и не рендерим ни одну из веток решения.
|
||||
if (domains.isError) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Не удалось загрузить список доменов</span>
|
||||
<span className="font-dns text-xs opacity-90">{domains.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
@@ -36,6 +95,7 @@ export function DomainDiffPage() {
|
||||
{id}
|
||||
</h1>
|
||||
</div>
|
||||
{hasTemplate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -45,16 +105,85 @@ export function DomainDiffPage() {
|
||||
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
||||
Recheck
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{check.isPending && (
|
||||
{!hasTemplate && (
|
||||
<>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-border bg-card/60 px-4 py-3 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<span>Шаблон не привязан — дифф недоступен. Ниже текущие записи зоны.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Создать шаблон-эталон из текущего состояния зоны (без NS/SOA).
|
||||
</span>
|
||||
<Button onClick={onCreateTemplateFromZone} disabled={createTemplateFromZone.isPending}>
|
||||
{createTemplateFromZone.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Play className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Создать шаблон из этой зоны
|
||||
</Button>
|
||||
</div>
|
||||
{createTemplateFromZone.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{createTemplateFromZone.error.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{zoneRecords.isPending && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
Загружаю записи зоны…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zoneRecords.isError && (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Не удалось получить записи зоны</span>
|
||||
<span className="font-dns text-xs opacity-90">{zoneRecords.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recordList.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>TTL</TableHead>
|
||||
<TableHead>Значение</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recordList.map((r, i) => (
|
||||
<TableRow key={`${r.type}-${r.name}-${i}`}>
|
||||
<TableCell className="font-dns">{r.type}</TableCell>
|
||||
<TableCell className="font-dns">{r.name}</TableCell>
|
||||
<TableCell className="font-dns">{r.ttl}</TableCell>
|
||||
<TableCell className="font-dns whitespace-pre-line">{r.values.join("\n")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasTemplate && check.isPending && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
Вычисляю дифф…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.isError && (
|
||||
{hasTemplate && check.isError && (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -64,7 +193,7 @@ export function DomainDiffPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeset && (
|
||||
{hasTemplate && changeset && (
|
||||
<>
|
||||
<DiffView changeset={changeset} />
|
||||
|
||||
|
||||
@@ -114,6 +114,22 @@ test("drift-badge отражает lastCheckStatus каждого домена",
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
expect(screen.getByText("drift")).toBeInTheDocument()
|
||||
expect(screen.getByText("in sync")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("домен без templateId показывает «без шаблона» вместо lastCheckStatus", async () => {
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
expect(screen.getByText("без шаблона")).toBeInTheDocument()
|
||||
expect(screen.queryByText("drift")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("кнопки удаления домена нет", async () => {
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
|
||||
import { Inbox, Loader2, Upload } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { StatusBadge } from "@/components/StatusBadge"
|
||||
import {
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
useAccounts,
|
||||
useDeleteDomain,
|
||||
useDomains,
|
||||
useImportZones,
|
||||
useSetDomainTemplate,
|
||||
@@ -35,7 +34,6 @@ export function DomainsPage() {
|
||||
const templates = useTemplates()
|
||||
const importZones = useImportZones()
|
||||
const setTemplate = useSetDomainTemplate()
|
||||
const deleteDomain = useDeleteDomain()
|
||||
|
||||
const accountList = accounts.data ?? []
|
||||
const templateList = templates.data ?? []
|
||||
@@ -64,12 +62,6 @@ export function DomainsPage() {
|
||||
setTemplate.mutate({ id: domainId, templateId })
|
||||
}
|
||||
|
||||
function onDelete(domainId: string, zoneName: string) {
|
||||
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
|
||||
deleteDomain.mutate(domainId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-col gap-1">
|
||||
@@ -117,12 +109,6 @@ export function DomainsPage() {
|
||||
{setTemplate.error?.message}
|
||||
</span>
|
||||
)}
|
||||
{deleteDomain.isError && (
|
||||
<span role="alert" className="font-dns text-xs text-destructive">
|
||||
{deleteDomain.error?.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{domainList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
@@ -170,22 +156,13 @@ export function DomainsPage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={d.lastCheckStatus} />
|
||||
<StatusBadge status={d.templateId ? d.lastCheckStatus : "no_template"} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
|
||||
Diff
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить ${d.zoneName}`}
|
||||
onClick={() => onDelete(d.id, d.zoneName)}
|
||||
disabled={deleteDomain.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
Reference in New Issue
Block a user