feat(web): view zone without template, snapshot button, no-template status, drop delete
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:
@@ -3,7 +3,7 @@ import type {
|
|||||||
AuthState,
|
AuthState,
|
||||||
Account, CreateAccountInput, Template, CreateTemplateInput,
|
Account, CreateAccountInput, Template, CreateTemplateInput,
|
||||||
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
||||||
Schedule, Channel, CreateChannelInput, CheckRun,
|
Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
export class UnauthorizedError extends Error {
|
||||||
@@ -74,6 +74,11 @@ export const api = {
|
|||||||
setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
|
setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
|
||||||
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
|
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
|
||||||
|
|
||||||
|
zoneRecords: (projectId: string, id: string) =>
|
||||||
|
req<RecordDTO[]>(projectPath(projectId, `/domains/${id}/records`)),
|
||||||
|
templateFromZone: (projectId: string, id: string) =>
|
||||||
|
req<Template>(projectPath(projectId, `/domains/${id}/template-from-zone`), { method: "POST" }),
|
||||||
|
|
||||||
checkDomain: (projectId: string, id: string) =>
|
checkDomain: (projectId: string, id: string) =>
|
||||||
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
|
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
|
||||||
applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
|
applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ test("unknown — muted, текст «unknown»", () => {
|
|||||||
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
|
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("no_template — muted, текст «без шаблона»", () => {
|
||||||
|
render(<StatusBadge status="no_template" />)
|
||||||
|
expect(screen.getByText("без шаблона")).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
|
||||||
|
})
|
||||||
|
|
||||||
test("отсутствие статуса трактуется как unknown", () => {
|
test("отсутствие статуса трактуется как unknown", () => {
|
||||||
render(<StatusBadge />)
|
render(<StatusBadge />)
|
||||||
expect(screen.getByText("unknown")).toBeInTheDocument()
|
expect(screen.getByText("unknown")).toBeInTheDocument()
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import { cn } from "@/lib/utils"
|
|||||||
// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already
|
// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already
|
||||||
// established for the domain-diff console so a drifted zone reads the same
|
// established for the domain-diff console so a drifted zone reads the same
|
||||||
// "amber" whether you're looking at the list or the diff view.
|
// "amber" whether you're looking at the list or the diff view.
|
||||||
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error"
|
// no_template is a frontend-only pseudo-status (backend never sends it) —
|
||||||
|
// shown when a domain has no template attached, so there's nothing to diff.
|
||||||
|
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" | "no_template"
|
||||||
|
|
||||||
const STATUS_META: Record<CheckStatus, { label: string; color: string }> = {
|
const STATUS_META: Record<CheckStatus, { label: string; color: string }> = {
|
||||||
in_sync: { label: "in sync", color: "var(--diff-add)" },
|
in_sync: { label: "in sync", color: "var(--diff-add)" },
|
||||||
drift: { label: "drift", color: "var(--diff-update)" },
|
drift: { label: "drift", color: "var(--diff-update)" },
|
||||||
error: { label: "error", color: "var(--diff-delete)" },
|
error: { label: "error", color: "var(--diff-delete)" },
|
||||||
unknown: { label: "unknown", color: "var(--diff-readonly)" },
|
unknown: { label: "unknown", color: "var(--diff-readonly)" },
|
||||||
|
no_template: { label: "без шаблона", color: "var(--diff-readonly)" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatus(status?: string): CheckStatus {
|
function resolveStatus(status?: string): CheckStatus {
|
||||||
if (status === "in_sync" || status === "drift" || status === "error") return status
|
if (status === "in_sync" || status === "drift" || status === "error" || status === "no_template") return status
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -122,14 +122,37 @@ export function useDeleteDomain() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
export function useCheckDomain(id: string) {
|
export function useCheckDomain(id: string, enabled = true) {
|
||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["check", project?.id, id],
|
queryKey: ["check", project?.id, id],
|
||||||
queryFn: () => api.checkDomain(project!.id, id),
|
queryFn: () => api.checkDomain(project!.id, id),
|
||||||
|
enabled: !!project && !!id && enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useZoneRecords(id: string) {
|
||||||
|
const { project } = useAuth()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["zoneRecords", project?.id, id],
|
||||||
|
queryFn: () => api.zoneRecords(project!.id, id),
|
||||||
enabled: !!project && !!id,
|
enabled: !!project && !!id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
export function useCreateTemplateFromZone() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.templateFromZone(pid, id)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["zoneRecords", project?.id, id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
export function useApplyDomain(id: string) {
|
export function useApplyDomain(id: string) {
|
||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|||||||
import { DomainDiffPage } from "./DomainDiffPage"
|
import { DomainDiffPage } from "./DomainDiffPage"
|
||||||
import { AuthProvider } from "@/auth/AuthContext"
|
import { AuthProvider } from "@/auth/AuthContext"
|
||||||
import { api } from "@/api/client"
|
import { api } from "@/api/client"
|
||||||
import { vi, beforeEach } from "vitest"
|
import { vi, beforeEach, test, expect } from "vitest"
|
||||||
|
import type { Domain } from "@/api/types"
|
||||||
|
|
||||||
const PROJECT_ID = "p1"
|
const PROJECT_ID = "p1"
|
||||||
|
|
||||||
|
const domainWithTemplate: Domain = {
|
||||||
|
id: "d1",
|
||||||
|
providerAccountId: "acc1",
|
||||||
|
zoneName: "example.com.",
|
||||||
|
zoneId: "z1",
|
||||||
|
templateId: "t1",
|
||||||
|
lastCheckStatus: "drift",
|
||||||
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
const qc = new QueryClient()
|
const qc = new QueryClient()
|
||||||
return render(
|
return render(
|
||||||
@@ -29,6 +39,7 @@ beforeEach(() => {
|
|||||||
project: { id: PROJECT_ID, name: "Default" },
|
project: { id: PROJECT_ID, name: "Default" },
|
||||||
})
|
})
|
||||||
vi.spyOn(api, "domainHistory").mockResolvedValue([])
|
vi.spyOn(api, "domainHistory").mockResolvedValue([])
|
||||||
|
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
||||||
@@ -53,3 +64,39 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
|||||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||||
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("домен без шаблона показывает записи зоны и не вызывает check", async () => {
|
||||||
|
vi.spyOn(api, "listDomains").mockResolvedValue([
|
||||||
|
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||||
|
])
|
||||||
|
const checkSpy = vi.spyOn(api, "checkDomain")
|
||||||
|
vi.spyOn(api, "zoneRecords").mockResolvedValue([
|
||||||
|
{ type: "A", name: "example.com.", ttl: 3600, values: ["1.2.3.4"] },
|
||||||
|
])
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/шаблон не привязан/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole("button", { name: /создать шаблон из этой зоны/i })).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText("example.com.")).toBeInTheDocument()
|
||||||
|
expect(screen.getByText("1.2.3.4")).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.queryByText(/вычисляю дифф/i)).not.toBeInTheDocument()
|
||||||
|
expect(checkSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("создание шаблона из зоны вызывает templateFromZone", async () => {
|
||||||
|
vi.spyOn(api, "listDomains").mockResolvedValue([
|
||||||
|
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||||
|
])
|
||||||
|
vi.spyOn(api, "zoneRecords").mockResolvedValue([])
|
||||||
|
const templateFromZoneSpy = vi.spyOn(api, "templateFromZone").mockResolvedValue({
|
||||||
|
id: "t9", name: "example.com. snapshot", records: [], version: 1,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const createBtn = await screen.findByRole("button", { name: /создать шаблон из этой зоны/i })
|
||||||
|
await user.click(createBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,13 +6,33 @@ import { DomainHistory } from "@/components/DomainHistory"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function DomainDiffPage() {
|
export function DomainDiffPage() {
|
||||||
const { id = "" } = useParams()
|
const { id = "" } = useParams()
|
||||||
const check = useCheckDomain(id)
|
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)
|
const apply = useApplyDomain(id)
|
||||||
|
const zoneRecords = useZoneRecords(id)
|
||||||
|
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||||
const pruneCheckboxId = useId()
|
const pruneCheckboxId = useId()
|
||||||
|
|
||||||
@@ -20,11 +40,16 @@ export function DomainDiffPage() {
|
|||||||
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
|
||||||
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
const hasUpdates = (changeset?.updates.length ?? 0) > 0
|
||||||
const pruneWarning = applyPrunes && hasPrunes
|
const pruneWarning = applyPrunes && hasPrunes
|
||||||
|
const recordList = zoneRecords.data ?? []
|
||||||
|
|
||||||
function onApply() {
|
function onApply() {
|
||||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
apply.mutate({ applyUpdates: true, applyPrunes })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCreateTemplateFromZone() {
|
||||||
|
createTemplateFromZone.mutate(id)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
<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">
|
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||||
@@ -36,6 +61,7 @@ export function DomainDiffPage() {
|
|||||||
{id}
|
{id}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{hasTemplate && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -45,16 +71,85 @@ export function DomainDiffPage() {
|
|||||||
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
|
||||||
Recheck
|
Recheck
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{check.isPending && (
|
{!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">{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">
|
<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} />
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
Вычисляю дифф…
|
Вычисляю дифф…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{check.isError && (
|
{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">
|
<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} />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -64,7 +159,7 @@ export function DomainDiffPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{changeset && (
|
{hasTemplate && changeset && (
|
||||||
<>
|
<>
|
||||||
<DiffView changeset={changeset} />
|
<DiffView changeset={changeset} />
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ test("drift-badge отражает lastCheckStatus каждого домена",
|
|||||||
|
|
||||||
await screen.findByText("example.com.")
|
await screen.findByText("example.com.")
|
||||||
|
|
||||||
expect(screen.getByText("drift")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("in sync")).toBeInTheDocument()
|
expect(screen.getByText("in sync")).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("домен без templateId показывает «без шаблона» вместо lastCheckStatus", async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByText("example.com.")
|
||||||
|
|
||||||
|
expect(screen.getByText("без шаблона")).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("drift")).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("кнопки удаления домена нет", async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByText("example.com.")
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
|
import { Inbox, Loader2, Upload } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { StatusBadge } from "@/components/StatusBadge"
|
import { StatusBadge } from "@/components/StatusBadge"
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
useAccounts,
|
useAccounts,
|
||||||
useDeleteDomain,
|
|
||||||
useDomains,
|
useDomains,
|
||||||
useImportZones,
|
useImportZones,
|
||||||
useSetDomainTemplate,
|
useSetDomainTemplate,
|
||||||
@@ -35,7 +34,6 @@ export function DomainsPage() {
|
|||||||
const templates = useTemplates()
|
const templates = useTemplates()
|
||||||
const importZones = useImportZones()
|
const importZones = useImportZones()
|
||||||
const setTemplate = useSetDomainTemplate()
|
const setTemplate = useSetDomainTemplate()
|
||||||
const deleteDomain = useDeleteDomain()
|
|
||||||
|
|
||||||
const accountList = accounts.data ?? []
|
const accountList = accounts.data ?? []
|
||||||
const templateList = templates.data ?? []
|
const templateList = templates.data ?? []
|
||||||
@@ -64,12 +62,6 @@ export function DomainsPage() {
|
|||||||
setTemplate.mutate({ id: domainId, templateId })
|
setTemplate.mutate({ id: domainId, templateId })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete(domainId: string, zoneName: string) {
|
|
||||||
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
|
|
||||||
deleteDomain.mutate(domainId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
||||||
<header className="flex flex-col gap-1">
|
<header className="flex flex-col gap-1">
|
||||||
@@ -117,12 +109,6 @@ export function DomainsPage() {
|
|||||||
{setTemplate.error?.message}
|
{setTemplate.error?.message}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{deleteDomain.isError && (
|
|
||||||
<span role="alert" className="font-dns text-xs text-destructive">
|
|
||||||
{deleteDomain.error?.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{domainList.length === 0 ? (
|
{domainList.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||||
<Inbox className="size-6" strokeWidth={1.5} />
|
<Inbox className="size-6" strokeWidth={1.5} />
|
||||||
@@ -170,22 +156,13 @@ export function DomainsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={d.lastCheckStatus} />
|
<StatusBadge status={d.templateId ? d.lastCheckStatus : "no_template"} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
|
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
|
||||||
Diff
|
Diff
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Удалить ${d.zoneName}`}
|
|
||||||
onClick={() => onDelete(d.id, d.zoneName)}
|
|
||||||
disabled={deleteDomain.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user