Files
dns-autoresolver/web/src/pages/DomainDiffPage.tsx
T
2026-07-05 15:20:09 +07:00

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react"
import { useParams } from "react-router-dom"
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
import { DiffView } from "@/components/DiffView"
import { DomainHistory } from "@/components/DomainHistory"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useApplyDomain,
useCheckDomain,
useCreateTemplateFromZone,
useDomains,
useZoneRecords,
} from "@/hooks/useApi"
import { cn } from "@/lib/utils"
export function DomainDiffPage() {
const { id = "" } = useParams()
const domains = useDomains()
const domain = domains.data?.find((d) => d.id === id)
const hasTemplate = !!domain?.templateId
const check = useCheckDomain(id, hasTemplate)
const apply = useApplyDomain(id)
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
// недостоверно (false по умолчанию из-за domain === undefined) — не
// дёргаем provider-запрос записей зоны, пока не будет точно известно
// (успешный ответ), что шаблона нет.
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
const createTemplateFromZone = useCreateTemplateFromZone()
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
const [selectedPrunes, setSelectedPrunes] = useState<Set<string>>(new Set())
const changeset = check.data
// Re-derive the selection whenever the changeset changes (initial load,
// recheck, or apply's own invalidation): updates default to fully selected
// (safe — they only bring the zone in line with the template), prunes
// default to empty (deletion is opt-in and irreversible).
useEffect(() => {
setSelectedUpdates(new Set((changeset?.updates ?? []).map((r) => r.key)))
setSelectedPrunes(new Set())
}, [changeset])
const recordList = zoneRecords.data ?? []
const hasSelection = selectedUpdates.size + selectedPrunes.size > 0
function toggleUpdate(key: string) {
setSelectedUpdates((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function togglePrune(key: string) {
setSelectedPrunes((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function toggleAllUpdates(checked: boolean) {
setSelectedUpdates(checked ? new Set((changeset?.updates ?? []).map((r) => r.key)) : new Set())
}
function toggleAllPrunes(checked: boolean) {
setSelectedPrunes(checked ? new Set((changeset?.prunes ?? []).map((r) => r.key)) : new Set())
}
function onApply() {
apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })
}
function onCreateTemplateFromZone() {
createTemplateFromZone.mutate(id)
}
if (domains.isPending) {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<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>
</div>
)
}
// Список доменов не загрузился — hasTemplate тут недостоверно (domain
// === undefined из-за domains.data === undefined даёт hasTemplate=false),
// поэтому без этой проверки страница молча уходит в ветку «без шаблона»
// и дёргает zoneRecords для несуществующего состояния. Показываем ошибку
// и не рендерим ни одну из веток решения.
if (domains.isError) {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<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">{domains.error.message}</span>
</div>
</div>
</div>
)
}
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>
{hasTemplate && (
<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>
{!hasTemplate && (
<>
<div className="flex items-start gap-2.5 rounded-lg border border-border bg-card/60 px-4 py-3 text-sm text-muted-foreground">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<span>Шаблон не привязан дифф недоступен. Ниже текущие записи зоны.</span>
</div>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/60 p-4">
<span className="text-xs text-muted-foreground">
Создать шаблон-эталон из текущего состояния зоны (без NS/SOA).
</span>
<Button onClick={onCreateTemplateFromZone} disabled={createTemplateFromZone.isPending}>
{createTemplateFromZone.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Play className="size-4" strokeWidth={1.75} />
)}
Создать шаблон из этой зоны
</Button>
</div>
{createTemplateFromZone.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{createTemplateFromZone.error.message}
</span>
)}
{zoneRecords.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>
)}
{zoneRecords.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">{zoneRecords.error.message}</span>
</div>
</div>
)}
{recordList.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>TTL</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordList.map((r, i) => (
<TableRow key={`${r.type}-${r.name}-${i}`}>
<TableCell className="font-dns">{r.type}</TableCell>
<TableCell className="font-dns">{r.name}</TableCell>
<TableCell className="font-dns">{r.ttl}</TableCell>
<TableCell className="font-dns whitespace-pre-line break-all">
{r.values.join("\n")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
{hasTemplate && 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>
)}
{hasTemplate && 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>
)}
{hasTemplate && changeset && (
<>
<DiffView
changeset={changeset}
selectedUpdates={selectedUpdates}
selectedPrunes={selectedPrunes}
onToggleUpdate={toggleUpdate}
onTogglePrune={togglePrune}
onToggleAllUpdates={toggleAllUpdates}
onToggleAllPrunes={toggleAllPrunes}
/>
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
{selectedPrunes.size > 0 && (
<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">{selectedPrunes.size}</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">
{hasSelection ? "Готово к применению" : "Изменений для применения нет"}
</span>
)}
<Button onClick={onApply} disabled={apply.isPending || !hasSelection}>
{apply.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Play className="size-4" strokeWidth={1.75} />
)}
Apply
</Button>
</div>
</div>
</>
)}
<DomainHistory domainId={id} />
</div>
)
}