2f1f5311ad
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|