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 ( +
+
+
+ + domain / check + +

+ {id} +

+
+ +
+ + {check.isPending && ( +
+ + Вычисляю дифф… +
+ )} + + {check.isError && ( +
+ +
+ Не удалось получить дифф + {check.error.message} +
+
+ )} + + {changeset && ( + <> + + +
+ + + {pruneWarning && ( +
+ + + Будет безвозвратно удалено записей:{" "} + {changeset.prunes.length}. Действие необратимо. + +
+ )} + +
+ {apply.isError ? ( + {apply.error.message} + ) : apply.isSuccess ? ( + + Применено ✓ + + ) : ( + + {hasUpdates || (applyPrunes && hasPrunes) + ? "Готово к применению" + : "Изменений для применения нет"} + + )} + +
+
+ + )} +
+ ) +}