feat(web): api client and hooks for custom record marks

This commit is contained in:
2026-08-19 18:16:42 +07:00
parent da267b3cef
commit 404f869d42
5 changed files with 60 additions and 12 deletions
+10
View File
@@ -83,6 +83,16 @@ export const api = {
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) =>
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }), req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }),
addCustom: (projectId: string, id: string, key: string) =>
req<void>(projectPath(projectId, `/domains/${id}/customs`), {
method: "POST",
body: JSON.stringify({ key }),
}),
removeCustom: (projectId: string, id: string, key: string) =>
req<void>(
projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`),
{ method: "DELETE" },
),
domainHistory: (projectId: string, id: string) => domainHistory: (projectId: string, id: string) =>
req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)), req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)),
+2
View File
@@ -45,10 +45,12 @@ export interface RecordView {
desired?: string[] desired?: string[]
actual?: string[] actual?: string[]
readOnly: boolean readOnly: boolean
custom: boolean // помечена оператором как живущая вне шаблона
} }
export interface ChangesetResponse { export interface ChangesetResponse {
updates: RecordView[] updates: RecordView[]
prunes: RecordView[] prunes: RecordView[]
customs: RecordView[]
readOnly: RecordView[] readOnly: RecordView[]
inSyncCount: number inSyncCount: number
} }
+10 -6
View File
@@ -4,9 +4,10 @@ import { DiffView } from "./DiffView"
import type { ChangesetResponse } from "@/api/types" import type { ChangesetResponse } from "@/api/types"
const cs: ChangesetResponse = { const cs: ChangesetResponse = {
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }], updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false }],
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }], prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false, custom: false }],
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }], customs: [],
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true, custom: false }],
inSyncCount: 3, inSyncCount: 3,
} }
@@ -60,9 +61,11 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
desired: [longValue], desired: [longValue],
actual: [], actual: [],
readOnly: false, readOnly: false,
custom: false,
}, },
], ],
prunes: [], prunes: [],
customs: [],
readOnly: [], readOnly: [],
inSyncCount: 0, inSyncCount: 0,
} }
@@ -135,11 +138,12 @@ test("select-all header checkbox calls onToggleAllUpdates(true) when clicked whi
test("select-all header checkbox is indeterminate when only some update rows are selected", () => { test("select-all header checkbox is indeterminate when only some update rows are selected", () => {
const csWithMultipleUpdates: ChangesetResponse = { const csWithMultipleUpdates: ChangesetResponse = {
updates: [ updates: [
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }, { key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false },
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false }, { key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false, custom: false },
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false }, { key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false, custom: false },
], ],
prunes: [], prunes: [],
customs: [],
readOnly: [], readOnly: [],
inSyncCount: 0, inSyncCount: 0,
} }
+30
View File
@@ -153,6 +153,36 @@ export function useApplyDomain(id: string) {
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
}) })
} }
export function useAddCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.addCustom(pid, id, key)
},
// Дифф пересчитывается на бэкенде — инвалидируем чек, а заодно статус
// домена в списке (custom-запись перестаёт быть дрифтом).
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
export function useRemoveCustom(id: string) {
const { project } = useAuth()
const qc = useQueryClient()
return useMutation({
mutationFn: (key: string) => {
const pid = requireProjectId(project)
return api.removeCustom(pid, id, key)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
},
})
}
export function useDomainHistory(id: string) { export function useDomainHistory(id: string) {
const { project } = useAuth() const { project } = useAuth()
return useQuery({ return useQuery({
+8 -6
View File
@@ -43,11 +43,12 @@ beforeEach(() => {
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => { test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({ vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }], prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false, custom: false }],
customs: [],
readOnly: [], inSyncCount: 0, readOnly: [], inSyncCount: 0,
}) })
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 })
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords") const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -78,11 +79,12 @@ test("default selection: updates checked, prunes unchecked; apply sends only sel
test("deselecting all records disables Apply", async () => { test("deselecting all records disables Apply", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({ vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
prunes: [], prunes: [],
customs: [],
readOnly: [], inSyncCount: 0, readOnly: [], inSyncCount: 0,
}) })
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 })
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -105,7 +107,7 @@ test("пока список доменов грузится — показан
}), }),
) )
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({ const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [], prunes: [], readOnly: [], inSyncCount: 0, updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0,
}) })
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords") const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
renderPage() renderPage()