diff --git a/web/src/App.tsx b/web/src/App.tsx
index a34d878..a2c4e2a 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout"
import { DomainDiffPage } from "@/pages/DomainDiffPage"
+import { DomainsPage } from "@/pages/DomainsPage"
function Placeholder({ name }: { name: string }) {
return
{name}
@@ -11,7 +12,7 @@ export function App() {
} />
- } />
+ } />
} />
} />
} />
diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx
new file mode 100644
index 0000000..2b2f402
--- /dev/null
+++ b/web/src/pages/DomainsPage.test.tsx
@@ -0,0 +1,88 @@
+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 { DomainsPage } from "./DomainsPage"
+import { api } from "@/api/client"
+import { vi, beforeEach, test, expect } from "vitest"
+import type { Account, Domain, Template } from "@/api/types"
+
+const accounts: Account[] = [
+ { id: "acc1", provider: "selectel", comment: "Main" },
+ { id: "acc2", provider: "cloudflare", comment: "Backup" },
+]
+const templates: Template[] = [
+ { id: "t1", name: "Standard", records: [], version: 1 },
+ { id: "t2", name: "Minimal", records: [], version: 1 },
+]
+const domains: Domain[] = [
+ { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
+ { id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1" },
+]
+
+function renderPage() {
+ const qc = new QueryClient()
+ return render(
+
+
+
+ } />
+ diff page} />
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ vi.spyOn(api, "listDomains").mockResolvedValue(domains)
+ vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
+ vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
+})
+
+test("отрисовывает домены и ссылку на diff-страницу", async () => {
+ renderPage()
+
+ expect(await screen.findByText("example.com.")).toBeInTheDocument()
+ expect(screen.getByText("test.org.")).toBeInTheDocument()
+
+ const links = screen.getAllByRole("link", { name: /diff/i })
+ expect(links.length).toBe(2)
+ expect(links[0]).toHaveAttribute("href", "/domains/d1")
+ expect(links[1]).toHaveAttribute("href", "/domains/d2")
+})
+
+test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
+ const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
+ const user = userEvent.setup()
+ renderPage()
+
+ await screen.findByText("example.com.")
+
+ await user.click(screen.getByRole("combobox", { name: /учётн/i }))
+ await user.click(await screen.findByRole("option", { name: /cloudflare/i }))
+
+ await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
+
+ await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2"))
+})
+
+test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
+ const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
+ const user = userEvent.setup()
+ renderPage()
+
+ await screen.findByText("example.com.")
+
+ await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
+ await user.click(await screen.findByRole("option", { name: /^standard$/i }))
+
+ await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1"))
+})
+
+test("пустое состояние при отсутствии доменов", async () => {
+ vi.spyOn(api, "listDomains").mockResolvedValue([])
+ renderPage()
+
+ expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument()
+})
diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx
new file mode 100644
index 0000000..3542352
--- /dev/null
+++ b/web/src/pages/DomainsPage.tsx
@@ -0,0 +1,183 @@
+import { useState } from "react"
+import { Link } from "react-router-dom"
+import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ useAccounts,
+ useDeleteDomain,
+ useDomains,
+ useImportZones,
+ useSetDomainTemplate,
+ useTemplates,
+} from "@/hooks/useApi"
+
+const NO_TEMPLATE = "__none__"
+
+export function DomainsPage() {
+ const domains = useDomains()
+ const accounts = useAccounts()
+ const templates = useTemplates()
+ const importZones = useImportZones()
+ const setTemplate = useSetDomainTemplate()
+ const deleteDomain = useDeleteDomain()
+
+ const accountList = accounts.data ?? []
+ const templateList = templates.data ?? []
+ const domainList = domains.data ?? []
+
+ const [importAccountId, setImportAccountId] = useState(null)
+ const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
+
+ const accountItems = accountList.map((a) => ({
+ value: a.id,
+ label: `${a.provider} · ${a.comment}`,
+ }))
+
+ function accountLabel(id: string) {
+ const acc = accountList.find((a) => a.id === id)
+ return acc ? `${acc.provider} · ${acc.comment}` : id
+ }
+
+ function onImport() {
+ if (!selectedImportAccount) return
+ importZones.mutate(selectedImportAccount)
+ }
+
+ function onTemplateChange(domainId: string, value: unknown) {
+ const templateId = value === NO_TEMPLATE ? null : (value as string)
+ setTemplate.mutate({ id: domainId, templateId })
+ }
+
+ function onDelete(domainId: string, zoneName: string) {
+ if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
+ deleteDomain.mutate(domainId)
+ }
+ }
+
+ return (
+
+
+
+
+
+ Учётная запись
+
+
+
+ {importZones.isError && (
+
{importZones.error.message}
+ )}
+
+
+ {domainList.length === 0 ? (
+
+
+ Доменов пока нет — импортируйте зоны из учётной записи.
+
+ ) : (
+
+
+
+ Zone
+ Учётка
+ Шаблон
+ Действия
+
+
+
+ {domainList.map((d) => {
+ const templateItems = [
+ { value: NO_TEMPLATE, label: "Без шаблона" },
+ ...templateList.map((t) => ({ value: t.id, label: t.name })),
+ ]
+ return (
+
+ {d.zoneName}
+
+ {accountLabel(d.providerAccountId)}
+
+
+
+
+
+
+ }>
+ Diff
+
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+ )
+}