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

39 KiB
Raw Blame History

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 builddist/ вшивается через 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 проксирует /apihttp://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-проект и зависимости

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 (начало):

@import "tailwindcss";

web/vite.config.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:

import "@testing-library/jest-dom"

web/tsconfig.json — добавить в compilerOptions:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  }
}

(и то же baseUrl/paths в tsconfig.app.json, чтобы редактор/сборка резолвили @.)

  • Step 3: Инициализировать shadcn и добавить компоненты
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-семантику:

/* Шрифты через 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); }

Установить шрифты:

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:

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):

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:

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 builddist/ собирается.

  • Step 8: Commit
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:

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:

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):

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:

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):

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
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:

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:

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
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

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

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

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:

.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 buildweb/dist/index.html и ассеты существуют.

  • Step 3: Падающий тест web.Handler

internal/web/web_test.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:

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 buildinternal/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
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) — при деплое.