docs: план реализации Фазы 1C (React SPA)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
# 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) — при деплое.
|
||||
Reference in New Issue
Block a user