diff --git a/web/src/App.tsx b/web/src/App.tsx
index 544993d..a34d878 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout"
+import { DomainDiffPage } from "@/pages/DomainDiffPage"
function Placeholder({ name }: { name: string }) {
return
{name}
@@ -11,7 +12,7 @@ export function App() {
} />
} />
- } />
+ } />
} />
} />
diff --git a/web/src/components/DiffView.test.tsx b/web/src/components/DiffView.test.tsx
new file mode 100644
index 0000000..50bba97
--- /dev/null
+++ b/web/src/components/DiffView.test.tsx
@@ -0,0 +1,28 @@
+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()
+ expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
+ expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
+ // Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
+ // "old.example.com." both contain "example.com." as a trailing substring,
+ // so the unanchored pattern matches all three rows and getByText throws
+ // "multiple elements found". Anchoring targets the read-only apex record
+ // specifically, which is what this assertion is actually verifying.
+ expect(screen.getByText(/^example\.com\.$/)).toBeInTheDocument()
+ expect(screen.getByText(/3/)).toBeInTheDocument() // in-sync count
+})
+
+test("marks read-only records", () => {
+ render()
+ expect(screen.getByText(/NS/)).toBeInTheDocument()
+})
diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx
new file mode 100644
index 0000000..6d2983a
--- /dev/null
+++ b/web/src/components/DiffView.tsx
@@ -0,0 +1,160 @@
+import type { ReactNode } from "react"
+import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import type { ChangesetResponse, RecordView } from "@/api/types"
+
+type Tone = "update" | "delete" | "readonly"
+
+const TONE_META: Record<
+ Tone,
+ { label: string; empty: string; icon: typeof Pencil; dot: string; ring: string }
+> = {
+ update: {
+ label: "Updates",
+ empty: "Нет изменений — все значения совпадают.",
+ icon: Pencil,
+ dot: "var(--diff-update)",
+ ring: "ring-[color-mix(in_oklch,var(--diff-update),transparent_78%)]",
+ },
+ delete: {
+ label: "Prunes",
+ empty: "Нечего удалять.",
+ icon: Trash2,
+ dot: "var(--diff-delete)",
+ ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
+ },
+ readonly: {
+ label: "Read-only",
+ empty: "Нет read-only записей.",
+ icon: Lock,
+ dot: "var(--diff-readonly)",
+ ring: "ring-[color-mix(in_oklch,var(--diff-readonly),transparent_82%)]",
+ },
+}
+
+function Values({ values }: { values?: string[] }) {
+ if (!values || values.length === 0) {
+ return —
+ }
+ return <>{values.join(", ")}>
+}
+
+function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
+ const meta = TONE_META[tone]
+ const showArrow = tone !== "delete"
+
+ return (
+
+
+ {record.type}
+
+
+
+ {record.name}
+
+
+
+
+ {showArrow && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {record.readOnly && (
+
+
+ read-only
+
+ )}
+
+ )
+}
+
+function Section({
+ tone,
+ records,
+}: {
+ tone: Tone
+ records: RecordView[]
+}) {
+ const meta = TONE_META[tone]
+ const Icon = meta.icon
+
+ return (
+
+
+
+
+ {meta.label}
+
+
+ {records.length}
+
+
+
+ {records.length === 0 ? (
+
+ {meta.empty}
+
+ ) : (
+
+ {records.map((record, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+export function DiffView({
+ changeset,
+ footerExtra,
+}: {
+ changeset: ChangesetResponse
+ footerExtra?: ReactNode
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {changeset.inSyncCount}
+ record{changeset.inSyncCount === 1 ? "" : "s"} in sync
+
+ {footerExtra}
+
+
+ )
+}
diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx
new file mode 100644
index 0000000..1c4c601
--- /dev/null
+++ b/web/src/pages/DomainDiffPage.test.tsx
@@ -0,0 +1,41 @@
+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(
+
+
+ } />
+
+ ,
+ )
+}
+
+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 })
+})
diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx
new file mode 100644
index 0000000..7749a99
--- /dev/null
+++ b/web/src/pages/DomainDiffPage.tsx
@@ -0,0 +1,142 @@
+import { useId, useState } from "react"
+import { useParams } from "react-router-dom"
+import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
+import { DiffView } from "@/components/DiffView"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Label } from "@/components/ui/label"
+import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
+import { cn } from "@/lib/utils"
+
+export function DomainDiffPage() {
+ const { id = "" } = useParams()
+ const check = useCheckDomain(id)
+ const apply = useApplyDomain(id)
+ const [applyPrunes, setApplyPrunes] = useState(false)
+ const pruneCheckboxId = useId()
+
+ const changeset = check.data
+ const hasPrunes = (changeset?.prunes.length ?? 0) > 0
+ const hasUpdates = (changeset?.updates.length ?? 0) > 0
+ const pruneWarning = applyPrunes && hasPrunes
+
+ function onApply() {
+ apply.mutate({ applyUpdates: true, applyPrunes })
+ }
+
+ return (
+
+
+
+ {check.isPending && (
+
+
+ Вычисляю дифф…
+
+ )}
+
+ {check.isError && (
+
+
+
+ Не удалось получить дифф
+ {check.error.message}
+
+
+ )}
+
+ {changeset && (
+ <>
+
+
+
+
+
+ {pruneWarning && (
+
+
+
+ Будет безвозвратно удалено записей:{" "}
+ {changeset.prunes.length}. Действие необратимо.
+
+
+ )}
+
+
+ {apply.isError ? (
+
{apply.error.message}
+ ) : apply.isSuccess ? (
+
+ Применено ✓
+
+ ) : (
+
+ {hasUpdates || (applyPrunes && hasPrunes)
+ ? "Готово к применению"
+ : "Изменений для применения нет"}
+
+ )}
+
+
+
+ >
+ )}
+
+ )
+}