Files
dns-autoresolver/docs/superpowers/plans/2026-07-03-phase1c-react-spa.md
T
2026-07-03 16:44:41 +07:00

855 lines
39 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.
# Phase 1C: React SPA — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. UI-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design для визуального качества. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Полноценный React SPA поверх готового REST API (1B): управление учётками/шаблонами/доменами, импорт зон, просмотр дифа с семантической подсветкой и применение с guard на удаление; в prod вшивается в Go-бинарь.
**Architecture:** `web/` — Vite + React + TS. TanStack Query для server-state, типизированный fetch-клиент к `/api/v1/projects/{DEFAULT_PROJECT_ID}`. shadcn/ui + Tailwind, тёмная «technical console» эстетика. В dev — Vite proxy на Go API; в prod — `vite build``dist/` вшивается через `embed.FS` в `internal/web`, отдаётся `cmd/server` с SPA-fallback (API-роуты приоритетны).
**Tech Stack:** Vite, React 18+, TypeScript, react-router-dom, @tanstack/react-query v5, Tailwind CSS v4 (`@tailwindcss/vite`), shadcn/ui, react-hook-form + zod, Vitest + React Testing Library. Пакетный менеджер — **npm**.
## Global Constraints
- Каталог фронта — `web/` в корне репозитория (Go-модуль `github.com/vasyakrg/dns-autoresolver`).
- `DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"` (seed default project; Фаза 1C без логина).
- API base: `/api/v1/projects/${DEFAULT_PROJECT_ID}`. В dev Vite проксирует `/api``http://localhost:8080`.
- Секрет учётки — только на вход (POST accounts); НИКОГДА не отображается обратно (API его и не возвращает).
- Apply: чекбокс prune **по умолчанию выключен**; включается только явным действием пользователя с визуальным предупреждением. Тело `POST apply`: `{applyUpdates:true, applyPrunes:<bool>}`.
- Диф-семантика и цвета (CSS-переменные): add=emerald, update=amber, delete/prune=rose, in-sync=muted, read-only=dimmed.
- Типографика: НЕ Inter/Roboto/system. UI-гротеск + моноширинный для DNS-данных/дифов.
- Эстетика: тёмная refined «technical console» (см. spec, секция «Фаза 1C»). UI-задачи используют skill frontend-design.
- Тесты: каждая задача с логикой завершается зелёным `npm run test` (Vitest) и коммитом. Go-задача — `go test`.
- Типы фронта (`api/types.ts`) зеркалят Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest).
---
### Task 1: Scaffold web/ (Vite + Tailwind + shadcn + router + тема)
**Files:**
- Create: `web/` (Vite react-ts проект), `web/vite.config.ts`, `web/tsconfig*.json`, `web/components.json`
- Create: `web/src/index.css` (тема, шрифты, diff-переменные), `web/src/main.tsx`, `web/src/App.tsx`
- Create: `web/src/components/Layout.tsx`
- Modify: `.gitignore` (web/node_modules, web/dist)
- Create: `web/src/App.test.tsx` (smoke)
**Interfaces:**
- Produces: рабочий dev-сервер (`npm run dev`) с proxy; alias `@`; тёмная тема; роутер-каркас со страницами-заглушками; Vitest настроен.
- [ ] **Step 1: Создать Vite-проект и зависимости**
```bash
cd /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/dns-autoresolver
npm create vite@latest web -- --template react-ts
cd web
npm install
npm install -D @tailwindcss/vite tailwindcss
npm install react-router-dom @tanstack/react-query
npm install react-hook-form zod @hookform/resolvers
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event
```
- [ ] **Step 2: Tailwind v4 + alias + proxy + vitest в конфиге**
`web/src/index.css` (начало):
```css
@import "tailwindcss";
```
`web/vite.config.ts`:
```ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test-setup.ts",
},
})
```
`web/src/test-setup.ts`:
```ts
import "@testing-library/jest-dom"
```
`web/tsconfig.json` — добавить в `compilerOptions`:
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}
}
```
(и то же `baseUrl`/`paths` в `tsconfig.app.json`, чтобы редактор/сборка резолвили `@`.)
- [ ] **Step 3: Инициализировать shadcn и добавить компоненты**
```bash
npx shadcn@latest init # base color: slate; CSS variables: yes; dark mode готов
npx shadcn@latest add button table dialog input label form card badge checkbox sonner separator textarea select
```
- [ ] **Step 4: Тема «technical console» + шрифты + diff-переменные**
Дополнить `web/src/index.css` (после shadcn-переменных): подключить характерные шрифты (не Inter/system) и mono, задать тёмную тему по умолчанию (`<html class="dark">` в `index.html`) и diff-семантику:
```css
/* Шрифты через Fontsource (устанавливаются): напр. @fontsource-variable/hanken-grotesk и @fontsource-variable/ibm-plex-mono */
:root {
--font-ui: "Hanken Grotesk Variable", ui-sans-serif, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--diff-add: oklch(0.72 0.15 155); /* emerald */
--diff-update: oklch(0.80 0.14 85); /* amber */
--diff-delete: oklch(0.68 0.19 20); /* rose */
--diff-insync: oklch(0.55 0.02 260); /* muted */
--diff-readonly: oklch(0.50 0.02 260); /* dimmed */
}
body { font-family: var(--font-ui); }
.font-dns { font-family: var(--font-mono); }
```
Установить шрифты:
```bash
npm install @fontsource-variable/hanken-grotesk @fontsource-variable/ibm-plex-mono
```
и импортировать их в `web/src/main.tsx`.
Реализатор: подключить skill frontend-design и довести тёмную «technical console» тему (фон-атмосфера, границы, тени) — это центральное впечатление продукта.
- [ ] **Step 5: main.tsx + App.tsx + Layout + роутер-каркас**
`web/src/main.tsx`:
```tsx
import "@fontsource-variable/hanken-grotesk"
import "@fontsource-variable/ibm-plex-mono"
import "./index.css"
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { BrowserRouter } from "react-router-dom"
import { App } from "./App"
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)
```
`web/src/App.tsx` — роуты со страницами-заглушками (реальные страницы — задачи 3-6):
```tsx
import { Routes, Route, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout"
function Placeholder({ name }: { name: string }) {
return <div className="p-8 text-2xl">{name}</div>
}
export function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/domains" replace />} />
<Route path="/domains" element={<Placeholder name="Domains" />} />
<Route path="/domains/:id" element={<Placeholder name="Domain diff" />} />
<Route path="/accounts" element={<Placeholder name="Accounts" />} />
<Route path="/templates" element={<Placeholder name="Templates" />} />
</Routes>
</Layout>
)
}
```
`web/src/components/Layout.tsx` — сайдбар/навигация (Domains/Accounts/Templates), тёмная console-эстетика (реализатор дорабатывает по frontend-design).
- [ ] **Step 6: Smoke-тест + .gitignore**
`.gitignore` (добавить):
```
web/node_modules/
web/dist/
```
`web/src/App.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { App } from "./App"
test("renders navigation and redirects to domains", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>
</QueryClientProvider>,
)
expect(screen.getByText("Domains")).toBeInTheDocument()
})
```
Добавить в `web/package.json` scripts: `"test": "vitest run"`, `"test:watch": "vitest"`.
- [ ] **Step 7: Проверки**
Run: `cd web && npm run test` → PASS (smoke).
Run: `cd web && npx tsc --noEmit` → без ошибок типов.
Run: `cd web && npm run build``dist/` собирается.
- [ ] **Step 8: Commit**
```bash
git add web/ .gitignore
git commit -m "feat(web): scaffold Vite+React+TS, Tailwind v4, shadcn, router, тёмная console-тема"
```
---
### Task 2: API-клиент, типы и Query-хуки
**Files:**
- Create: `web/src/lib/config.ts`, `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/api/client.test.ts`
- Create: `web/src/hooks/useApi.ts`
**Interfaces:**
- Consumes: Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest — сверить с `internal/api/dto.go`, `tenant_dto.go`)
- Produces:
- `DEFAULT_PROJECT_ID`
- типы `Account, Template, Domain, RecordView, ChangesetResponse, RecordDTO, ApplyRequest`
- `api` — объект с методами: `listAccounts/createAccount/deleteAccount`, `listTemplates/createTemplate/updateTemplate/deleteTemplate`, `listDomains/createDomain/deleteDomain/importZones/setDomainTemplate`, `checkDomain/applyDomain`
- хуки: `useAccounts, useCreateAccount, useDeleteAccount, useTemplates, ...`, `useDomains, useImportZones, useSetDomainTemplate, useCheckDomain, useApplyDomain`
- [ ] **Step 1: Написать падающий тест клиента**
`web/src/api/client.test.ts`:
```ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { api } from "./client"
import { DEFAULT_PROJECT_ID } from "@/lib/config"
beforeEach(() => { vi.restoreAllMocks() })
function mockFetch(body: unknown, ok = true, status = 200) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok, status,
json: async () => body,
text: async () => JSON.stringify(body),
} as Response)
}
describe("api client", () => {
it("lists accounts at project-scoped path", async () => {
const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }])
const accounts = await api.listAccounts()
expect(accounts).toHaveLength(1)
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`,
expect.objectContaining({ method: "GET" }),
)
})
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" })
const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).method).toBe("POST")
expect(String((opts as RequestInit).body)).toContain("TOKEN")
})
it("throws on non-ok response", async () => {
mockFetch({ error: "boom" }, false, 500)
await expect(api.listDomains()).rejects.toThrow()
})
it("applies with prune flag", async () => {
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true })
const [url, opts] = spy.mock.calls[0]
expect(url).toContain("/domains/d1/apply")
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
})
})
```
- [ ] **Step 2: Запустить — падает**
Run: `cd web && npm run test -- client` → FAIL.
- [ ] **Step 3: config + types + client**
`web/src/lib/config.ts`:
```ts
export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"
export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}`
```
`web/src/api/types.ts` (зеркало Go DTO — сверить имена полей с internal/api):
```ts
export interface Account { id: string; provider: string; comment: string }
export interface CreateAccountInput { provider: string; secret: string; comment: string }
export interface RecordDTO { type: string; name: string; ttl: number; values: string[] }
export interface Template { id: string; name: string; records: RecordDTO[] }
export interface CreateTemplateInput { name: string; records: RecordDTO[] }
export interface Domain {
id: string
providerAccountId: string
zoneName: string
zoneId: string
templateId: string | null
}
export interface CreateDomainInput {
providerAccountId: string
zoneName: string
zoneId: string
templateId?: string | null
}
export interface RecordView {
kind: string // add | update | delete | in_sync
type: string
name: string
desired?: string[]
actual?: string[]
readOnly: boolean
}
export interface ChangesetResponse {
updates: RecordView[]
prunes: RecordView[]
readOnly: RecordView[]
inSyncCount: number
}
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
```
`web/src/api/client.ts`:
```ts
import { API_BASE } from "@/lib/config"
import type {
Account, CreateAccountInput, Template, CreateTemplateInput,
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
} from "./types"
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
method: "GET",
...init,
})
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { const b = await res.json(); if (b?.error) msg = b.error } catch { /* ignore */ }
throw new Error(msg)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
listAccounts: () => req<Account[]>("/accounts"),
createAccount: (input: CreateAccountInput) =>
req<Account>("/accounts", { method: "POST", body: JSON.stringify(input) }),
deleteAccount: (id: string) => req<void>(`/accounts/${id}`, { method: "DELETE" }),
listTemplates: () => req<Template[]>("/templates"),
createTemplate: (input: CreateTemplateInput) =>
req<Template>("/templates", { method: "POST", body: JSON.stringify(input) }),
updateTemplate: (id: string, input: CreateTemplateInput) =>
req<Template>(`/templates/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteTemplate: (id: string) => req<void>(`/templates/${id}`, { method: "DELETE" }),
listDomains: () => req<Domain[]>("/domains"),
createDomain: (input: CreateDomainInput) =>
req<Domain>("/domains", { method: "POST", body: JSON.stringify(input) }),
deleteDomain: (id: string) => req<void>(`/domains/${id}`, { method: "DELETE" }),
importZones: (accountId: string) =>
req<Domain[]>(`/accounts/${accountId}/import`, { method: "POST" }),
setDomainTemplate: (id: string, templateId: string | null) =>
req<Domain>(`/domains/${id}`, { method: "PATCH", body: JSON.stringify({ templateId }) }),
checkDomain: (id: string) => req<ChangesetResponse>(`/domains/${id}/check`),
applyDomain: (id: string, body: ApplyRequest) =>
req<ChangesetResponse>(`/domains/${id}/apply`, { method: "POST", body: JSON.stringify(body) }),
}
```
> Реализатор: сверь имена JSON-полей ответов (`updates/prunes/readOnly/inSyncCount`, `zoneName/zoneId/templateId`, `providerAccountId`) с фактическими json-тегами в `internal/api/dto.go` и `tenant_dto.go`. При расхождении — правь `types.ts`, тесты самосогласованы.
- [ ] **Step 4: Query-хуки**
`web/src/hooks/useApi.ts` — обёртки TanStack Query. Паттерн (полностью для accounts, аналогично для templates/domains):
```ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { api } from "@/api/client"
import type { CreateAccountInput, CreateTemplateInput, CreateDomainInput, ApplyRequest } from "@/api/types"
export function useAccounts() {
return useQuery({ queryKey: ["accounts"], queryFn: api.listAccounts })
}
export function useCreateAccount() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateAccountInput) => api.createAccount(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
})
}
export function useDeleteAccount() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
})
}
export function useTemplates() {
return useQuery({ queryKey: ["templates"], queryFn: api.listTemplates })
}
export function useCreateTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateTemplateInput) => api.createTemplate(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useUpdateTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => api.updateTemplate(id, input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useDeleteTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteTemplate(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useDomains() {
return useQuery({ queryKey: ["domains"], queryFn: api.listDomains })
}
export function useImportZones() {
const qc = useQueryClient()
return useMutation({
mutationFn: (accountId: string) => api.importZones(accountId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useSetDomainTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => api.setDomainTemplate(id, templateId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useDeleteDomain() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteDomain(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useCheckDomain(id: string) {
return useQuery({ queryKey: ["check", id], queryFn: () => api.checkDomain(id), enabled: !!id })
}
export function useApplyDomain(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ApplyRequest) => api.applyDomain(id, body),
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", id] }),
})
}
```
- [ ] **Step 5: Тесты клиента зелёные**
Run: `cd web && npm run test -- client` → PASS (4 теста). `npx tsc --noEmit` → чисто.
- [ ] **Step 6: Commit**
```bash
git add web/src/lib web/src/api web/src/hooks
git commit -m "feat(web): типизированный API-клиент, типы DTO, TanStack Query хуки"
```
---
### Task 3: DiffView + DomainDiffPage (центральный экран, apply-guard)
**Files:**
- Create: `web/src/components/DiffView.tsx`, `web/src/components/DiffView.test.tsx`
- Create: `web/src/pages/DomainDiffPage.tsx`, `web/src/pages/DomainDiffPage.test.tsx`
- Modify: `web/src/App.tsx` (роут `/domains/:id`)
**Interfaces:**
- Consumes: `ChangesetResponse`, `RecordView`, `useCheckDomain`, `useApplyDomain`
- Produces: `DiffView` (секции Updates/Prunes/ReadOnly + in-sync counter, семантические цвета, mono для записей); `DomainDiffPage` (check + Apply с prune-guard)
**Дизайн:** используй skill frontend-design. Диф — самый выразительный экран: моноширинные записи, цветовые полосы по семантике, чёткая иерархия секций.
- [ ] **Step 1: Падающий тест DiffView**
`web/src/components/DiffView.test.tsx`:
```tsx
import { render, screen } from "@testing-library/react"
import { DiffView } from "./DiffView"
import type { ChangesetResponse } from "@/api/types"
const cs: ChangesetResponse = {
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
inSyncCount: 3,
}
test("renders all sections with counts", () => {
render(<DiffView changeset={cs} />)
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
expect(screen.getByText(/example\.com\./)).toBeInTheDocument()
expect(screen.getByText(/3/)).toBeInTheDocument() // in-sync count
})
test("marks read-only records", () => {
render(<DiffView changeset={cs} />)
expect(screen.getByText(/NS/)).toBeInTheDocument()
})
```
- [ ] **Step 2: Запустить — падает**
Run: `cd web && npm run test -- DiffView` → FAIL.
- [ ] **Step 3: Реализовать DiffView**
`web/src/components/DiffView.tsx` — секции по `changeset.updates` (amber), `changeset.prunes` (rose), `changeset.readOnly` (dimmed), футер с `inSyncCount`. Каждая запись — mono (`className="font-dns"`), показывает `name`, `type`, desired→actual. Пустые секции скрываются или помечаются «нет изменений». Реальный JSX с семантическими классами/цветами (реализатор доводит визуал по frontend-design; тест выше фиксирует контент).
- [ ] **Step 4: Падающий тест DomainDiffPage (apply-guard)**
`web/src/pages/DomainDiffPage.test.tsx`:
```tsx
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainDiffPage } from "./DomainDiffPage"
import { api } from "@/api/client"
import { vi } from "vitest"
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/domains/d1"]}>
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
readOnly: [], inSyncCount: 0,
})
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
const user = userEvent.setup()
renderPage()
const applyBtn = await screen.findByRole("button", { name: /apply/i })
await user.click(applyBtn)
await waitFor(() => expect(applySpy).toHaveBeenCalled())
expect(applySpy.mock.calls[0][1]).toEqual({ applyUpdates: true, applyPrunes: false })
// включить prune и применить снова
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
await user.click(pruneToggle)
await user.click(screen.getByRole("button", { name: /apply/i }))
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
expect(applySpy.mock.calls[1][1]).toEqual({ applyUpdates: true, applyPrunes: true })
})
```
- [ ] **Step 5: Реализовать DomainDiffPage**
`web/src/pages/DomainDiffPage.tsx`: `useParams` id → `useCheckDomain(id)` → лоадинг/ошибка/`<DiffView>`. Состояние `applyPrunes` (default `false`) через `checkbox`. Кнопка Apply → `useApplyDomain(id).mutate({applyUpdates:true, applyPrunes})`. Если есть prune-записи и `applyPrunes` включён — визуальное предупреждение (rose) рядом с чекбоксом. Подключить роут в `App.tsx`.
- [ ] **Step 6: Тесты зелёные**
Run: `cd web && npm run test -- DiffView DomainDiffPage` → PASS. `npx tsc --noEmit` чисто.
- [ ] **Step 7: Commit**
```bash
git add web/src/components/DiffView.tsx web/src/components/DiffView.test.tsx web/src/pages/DomainDiffPage.tsx web/src/pages/DomainDiffPage.test.tsx web/src/App.tsx
git commit -m "feat(web): DiffView + DomainDiffPage с prune-guard по умолчанию выключенным"
```
---
### Task 4: DomainsPage (список, импорт, привязка шаблона)
**Files:**
- Create: `web/src/pages/DomainsPage.tsx`, `web/src/pages/DomainsPage.test.tsx`
- Modify: `web/src/App.tsx` (роут `/domains`)
**Interfaces:**
- Consumes: `useDomains, useImportZones, useSetDomainTemplate, useDeleteDomain, useAccounts, useTemplates`
- Produces: `DomainsPage`
- [ ] **Step 1: Падающий тест**
`web/src/pages/DomainsPage.test.tsx`: мокнуть `api.listDomains` (2 домена), `api.listAccounts`, `api.listTemplates`; проверить, что домены отрисованы, ссылка на `/domains/:id` есть, кнопка импорта вызывает `api.importZones` с выбранной учёткой, привязка шаблона вызывает `api.setDomainTemplate`. (Паттерн рендера с провайдерами — как в DomainDiffPage.test.)
- [ ] **Step 2: Запустить — падает** → FAIL.
- [ ] **Step 3: Реализовать DomainsPage**
Таблица доменов (shadcn Table): zoneName (mono), провайдер-учётка, привязанный шаблон (select → `setDomainTemplate`), ссылка «Diff» → `/domains/:id`, удаление. Блок импорта: выбор учётки + кнопка «Импортировать зоны» → `useImportZones`. Пустое состояние. Реализатор доводит визуал (frontend-design).
- [ ] **Step 4: Тесты зелёные**`cd web && npm run test -- DomainsPage` PASS; `npx tsc --noEmit` чисто.
- [ ] **Step 5: Commit**
```bash
git add web/src/pages/DomainsPage.tsx web/src/pages/DomainsPage.test.tsx web/src/App.tsx
git commit -m "feat(web): DomainsPage — список, импорт зон, привязка шаблона"
```
---
### Task 5: AccountsPage (CRUD, secret-форма, инструкция Selectel)
**Files:**
- Create: `web/src/pages/AccountsPage.tsx`, `web/src/pages/AccountsPage.test.tsx`
- Modify: `web/src/App.tsx` (роут `/accounts`)
**Interfaces:**
- Consumes: `useAccounts, useCreateAccount, useDeleteAccount`
- Produces: `AccountsPage`
- [ ] **Step 1: Падающий тест**
`AccountsPage.test.tsx`: мокнуть `api.listAccounts`; форма создания (provider=selectel, secret, comment) вызывает `api.createAccount` с введённым secret; в списке учёток секрет НЕ отображается (в DOM нет введённого токена после создания). Проверить наличие ссылки-инструкции получения ключа Selectel.
- [ ] **Step 2: Запустить — падает** → FAIL.
- [ ] **Step 3: Реализовать AccountsPage**
Список учёток (провайдер, comment, без секрета). Форма создания (react-hook-form + zod): поле provider (пока `selectel`), поле API-ключа (`type="password"`), comment; блок-подсказка с текстом и ссылкой на страницу получения ключа в панели Selectel (`https://my.selectel.ru` / доки). Submit → `useCreateAccount`. Удаление. Секрет после отправки не хранится/не показывается.
- [ ] **Step 4: Тесты зелёные**`cd web && npm run test -- AccountsPage` PASS; `npx tsc --noEmit` чисто.
- [ ] **Step 5: Commit**
```bash
git add web/src/pages/AccountsPage.tsx web/src/pages/AccountsPage.test.tsx web/src/App.tsx
git commit -m "feat(web): AccountsPage — CRUD учёток, secret-форма, инструкция Selectel"
```
---
### Task 6: TemplatesPage + RecordEditor (CRUD шаблонов)
**Files:**
- Create: `web/src/pages/TemplatesPage.tsx`, `web/src/pages/TemplatesPage.test.tsx`
- Create: `web/src/components/RecordEditor.tsx`, `web/src/components/RecordEditor.test.tsx`
- Modify: `web/src/App.tsx` (роут `/templates`)
**Interfaces:**
- Consumes: `useTemplates, useCreateTemplate, useUpdateTemplate, useDeleteTemplate`, `RecordDTO`
- Produces: `RecordEditor` (редактор массива `RecordDTO`), `TemplatesPage`
- [ ] **Step 1: Падающий тест RecordEditor**
`RecordEditor.test.tsx`: контролируемый компонент `value: RecordDTO[]`, `onChange`. Добавление записи (type=A, name, ttl, values), изменение, удаление — вызывает `onChange` с корректным массивом. Поддержка SRV (values = "prio weight port target"). Реальные ассерты на `onChange`.
- [ ] **Step 2: Запустить — падает** → FAIL.
- [ ] **Step 3: Реализовать RecordEditor**
Редактор списка записей: строка = select type (A/AAAA/CNAME/MX/TXT/SRV/NS/SOA), input name (mono), input ttl (number), values (textarea/список; для нескольких значений — по строке). Кнопки add/remove. Контролируемый через `value`/`onChange`. Mono-шрифт для name/values.
- [ ] **Step 4: Падающий тест TemplatesPage**
`TemplatesPage.test.tsx`: мокнуть `api.listTemplates`; создание шаблона (name + записи через RecordEditor) вызывает `api.createTemplate` с `{name, records}`; список отображает шаблоны; удаление вызывает `api.deleteTemplate`.
- [ ] **Step 5: Реализовать TemplatesPage**
Список шаблонов (name, число записей). Форма создания/редактирования: name + `<RecordEditor>`; submit → `useCreateTemplate`/`useUpdateTemplate`. Удаление. Реализатор доводит визуал.
- [ ] **Step 6: Тесты зелёные**`cd web && npm run test -- RecordEditor TemplatesPage` PASS; `npx tsc --noEmit` чисто; `npm run build` собирается.
- [ ] **Step 7: Commit**
```bash
git add web/src/pages/TemplatesPage.tsx web/src/pages/TemplatesPage.test.tsx web/src/components/RecordEditor.tsx web/src/components/RecordEditor.test.tsx web/src/App.tsx
git commit -m "feat(web): TemplatesPage + RecordEditor — CRUD шаблонов с редактором записей"
```
---
### Task 7: Go embed статики + SPA-fallback в cmd/server
**Files:**
- Create: `internal/web/web.go`, `internal/web/web_test.go`
- Modify: `cmd/server/main.go` (монтирование статики)
- Modify: `Makefile` (цель сборки фронта)
**Interfaces:**
- Consumes: собранный `web/dist`
- Produces:
- `func Handler() (http.Handler, error)` — отдаёт вшитую статику из `web/dist`, с SPA-fallback на `index.html` для не-API, не-файловых путей
- монтирование в `cmd/server`: `/api/v1` (chi API) приоритетно, всё прочее → `web.Handler()`
**Замечание:** `embed` требует, чтобы `web/dist` существовал на момент `go build`. Для этого Makefile сначала собирает фронт. В тесте — если `dist` пуст, тест это учитывает (skip или минимальный fixture); предпочтительно собрать фронт перед `go test ./internal/web`.
- [ ] **Step 1: Makefile — сборка фронта**
Добавить в `Makefile`:
```makefile
.PHONY: web
web:
cd web && npm ci && npm run build
.PHONY: build-all
build-all: web build
```
- [ ] **Step 2: Собрать фронт (нужно для embed)**
Run: `cd web && npm run build``web/dist/index.html` и ассеты существуют.
- [ ] **Step 3: Падающий тест web.Handler**
`internal/web/web_test.go`:
```go
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerServesIndexAndSPAFallback(t *testing.T) {
h, err := Handler()
if err != nil {
t.Fatalf("handler: %v", err)
}
// корень → 200 и HTML
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("root status %d", rec.Code)
}
// неизвестный клиентский путь → SPA fallback (index.html), не 404
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/domains/xyz", nil))
if rec2.Code != http.StatusOK {
t.Fatalf("SPA fallback status %d", rec2.Code)
}
}
```
- [ ] **Step 4: Реализовать web.Handler**
`internal/web/web.go`:
```go
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// Handler serves the embedded SPA with fallback to index.html for
// client-side routes (any non-file path that isn't an API route).
func Handler() (http.Handler, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, err
}
fileServer := http.FileServer(http.FS(sub))
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// существующий файл (ассет) — отдать как есть
p := strings.TrimPrefix(r.URL.Path, "/")
if p != "" {
if f, err := sub.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
}
// иначе — SPA index
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
}), nil
}
```
> `//go:embed all:dist` требует наличия `web/dist`, но пакет лежит в `internal/web`. Реализатор: положи `dist` рядом с `web.go` (симлинк/копия из `web/dist`) ИЛИ измени встраивание, чтобы путь резолвился. Рекомендуемый вариант — цель Makefile копирует `web/dist` в `internal/web/dist` перед `go build` (и `internal/web/dist` в `.gitignore`). Отрази это в Makefile и тесте.
- [ ] **Step 5: Монтирование в cmd/server**
Обновить `cmd/server/main.go`: API-роутер chi под `/api/v1`, а корень отдать `web.Handler()`. Вариант — обернуть: если путь начинается с `/api/`, отдать API-роутеру, иначе — web-хендлеру. Логировать старт. Ошибку `web.Handler()` не считать фатальной для API (можно поднять API без фронта, если dist отсутствует), но логировать.
- [ ] **Step 6: Проверки**
Run: `make web` (собрать фронт) затем `go test ./internal/web/ -v` → PASS.
Run: `go build ./...` → компилируется.
Run: `go test ./...` → все Go-пакеты зелёные (store — Docker).
- [ ] **Step 7: Commit**
```bash
git add internal/web/ cmd/server/main.go Makefile .gitignore
git commit -m "feat(web,server): embed статики SPA + fallback, монтирование в cmd/server"
```
---
## Self-Review
- **Spec coverage:** scaffold+тема (T1), API-клиент+типы+хуки (T2), DiffView+DomainDiff с prune-guard (T3 — центральный экран), Domains+импорт+привязка шаблона (T4), Accounts CRUD+secret+Selectel-инструкция (T5), Templates+RecordEditor включая SRV (T6), Go embed+SPA-fallback+wiring (T7). Все экраны и подача из spec-секции 1C покрыты.
- **Type consistency:** `types.ts` (Account/Template/Domain/RecordView/ChangesetResponse/ApplyRequest) едины в T2-T6; `api`-методы (client.ts) и хуки (useApi.ts) согласованы; `DEFAULT_PROJECT_ID` из config; prune-guard `{applyUpdates:true, applyPrunes:bool}` един в client/hook/DomainDiffPage.
- **Placeholders:** нет кода-плейсхолдера. Пометки «реализатор доводит визуал (frontend-design)» — намеренные точки эстетической доработки, не логические пробелы; функциональная логика и тесты заданы. Пометка про сверку json-тегов с Go DTO — точка синхронизации с 1B (образцы полей приведены).
## Проверка (end-to-end)
1. `cd web && npm install && npm run test && npx tsc --noEmit && npm run build` — фронт собирается, тесты зелёные.
2. `make web` копирует/собирает `dist`; `go build ./...` вшивает статику; `go test ./...` зелёно.
3. Ручной прогон: поднять Postgres, задать env, `go run ./cmd/server`; открыть `http://localhost:8080` — SPA грузится; создать учётку Selectel (secret не виден после), импортировать зоны, привязать шаблон, открыть Diff — секции Updates/Prunes/ReadOnly с цветами; Apply без prune не трогает удаления; включить prune (с предупреждением) — применяет.
4. Playwright/визуальная проверка на PROD (по правилам validation) — при деплое.