feat(web): DiffView + DomainDiffPage с prune-guard по умолчанию выключенным
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom"
|
import { Routes, Route, Navigate } from "react-router-dom"
|
||||||
import { Layout } from "@/components/Layout"
|
import { Layout } from "@/components/Layout"
|
||||||
|
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||||
|
|
||||||
function Placeholder({ name }: { name: string }) {
|
function Placeholder({ name }: { name: string }) {
|
||||||
return <div className="p-8 text-2xl">{name}</div>
|
return <div className="p-8 text-2xl">{name}</div>
|
||||||
@@ -11,7 +12,7 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||||
<Route path="/domains" element={<Placeholder name="Domains" />} />
|
<Route path="/domains" element={<Placeholder name="Domains" />} />
|
||||||
<Route path="/domains/:id" element={<Placeholder name="Domain diff" />} />
|
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||||
<Route path="/accounts" element={<Placeholder name="Accounts" />} />
|
<Route path="/accounts" element={<Placeholder name="Accounts" />} />
|
||||||
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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(<DiffView changeset={cs} />)
|
||||||
|
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(<DiffView changeset={cs} />)
|
||||||
|
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
@@ -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 <span className="text-muted-foreground/50">—</span>
|
||||||
|
}
|
||||||
|
return <>{values.join(", ")}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
||||||
|
const meta = TONE_META[tone]
|
||||||
|
const showArrow = tone !== "delete"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/row flex items-center gap-3 border-l-2 px-3 py-2.5 transition-colors",
|
||||||
|
"hover:bg-foreground/[0.025]",
|
||||||
|
)}
|
||||||
|
style={{ borderLeftColor: meta.dot }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
{record.type}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<span className="font-dns min-w-0 flex-1 truncate text-sm text-foreground">
|
||||||
|
{record.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="font-dns hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
|
||||||
|
<Values values={record.actual} />
|
||||||
|
{showArrow && (
|
||||||
|
<>
|
||||||
|
<ArrowRight className="size-3 text-muted-foreground/50" strokeWidth={1.75} />
|
||||||
|
<span style={{ color: meta.dot }}>
|
||||||
|
<Values values={record.desired} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{record.readOnly && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-1 shrink-0 gap-1 bg-muted text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Lock className="size-2.5" strokeWidth={2} />
|
||||||
|
read-only
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
tone,
|
||||||
|
records,
|
||||||
|
}: {
|
||||||
|
tone: Tone
|
||||||
|
records: RecordView[]
|
||||||
|
}) {
|
||||||
|
const meta = TONE_META[tone]
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label={meta.label} className="flex flex-col gap-2">
|
||||||
|
<header className="flex items-center gap-2 px-0.5">
|
||||||
|
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
||||||
|
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||||
|
{meta.label}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="outline" className="font-dns h-4.5 px-1.5 text-[10px] text-muted-foreground">
|
||||||
|
{records.length}
|
||||||
|
</Badge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground/70">
|
||||||
|
{meta.empty}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col divide-y divide-border rounded-lg bg-card ring-1",
|
||||||
|
meta.ring,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{records.map((record, i) => (
|
||||||
|
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffView({
|
||||||
|
changeset,
|
||||||
|
footerExtra,
|
||||||
|
}: {
|
||||||
|
changeset: ChangesetResponse
|
||||||
|
footerExtra?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Section tone="update" records={changeset.updates} />
|
||||||
|
<Section tone="delete" records={changeset.prunes} />
|
||||||
|
<Section tone="readonly" records={changeset.readOnly} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<CircleCheck
|
||||||
|
className="size-3.5"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
style={{ color: "var(--diff-insync)" }}
|
||||||
|
/>
|
||||||
|
<span className="font-dns">{changeset.inSyncCount}</span>
|
||||||
|
<span>record{changeset.inSyncCount === 1 ? "" : "s"} in sync</span>
|
||||||
|
</div>
|
||||||
|
{footerExtra}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<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 })
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||||
|
domain / check
|
||||||
|
</span>
|
||||||
|
<h1 className="font-dns text-xl font-semibold tracking-tight text-foreground">
|
||||||
|
{id}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => check.refetch()}
|
||||||
|
disabled={check.isFetching}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
||||||
|
Recheck
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{check.isPending && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
|
Вычисляю дифф…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{check.isError && (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium">Не удалось получить дифф</span>
|
||||||
|
<span className="font-dns text-xs opacity-90">{check.error.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeset && (
|
||||||
|
<>
|
||||||
|
<DiffView changeset={changeset} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||||
|
<Label
|
||||||
|
htmlFor={pruneCheckboxId}
|
||||||
|
className="flex items-start gap-2.5 text-sm font-normal"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={pruneCheckboxId}
|
||||||
|
aria-label="prune — удалить лишние записи"
|
||||||
|
checked={applyPrunes}
|
||||||
|
onCheckedChange={(v) => setApplyPrunes(v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
style={
|
||||||
|
applyPrunes
|
||||||
|
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
Prune — удалить записи, которых нет в шаблоне
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
По умолчанию выключено. Apply меняет только записи из шаблона.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{pruneWarning && (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||||
|
style={{
|
||||||
|
color: "var(--diff-delete)",
|
||||||
|
background: "color-mix(in oklch, var(--diff-delete), transparent 90%)",
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||||
|
<span>
|
||||||
|
Будет безвозвратно удалено записей:{" "}
|
||||||
|
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||||
|
{apply.isError ? (
|
||||||
|
<span className="font-dns text-xs text-destructive">{apply.error.message}</span>
|
||||||
|
) : apply.isSuccess ? (
|
||||||
|
<span className="font-dns text-xs" style={{ color: "var(--diff-add)" }}>
|
||||||
|
Применено ✓
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{hasUpdates || (applyPrunes && hasPrunes)
|
||||||
|
? "Готово к применению"
|
||||||
|
: "Изменений для применения нет"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button onClick={onApply} disabled={apply.isPending}>
|
||||||
|
{apply.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<Play className="size-4" strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user