feat(web,api): клиент/хуки расписания/каналов/истории + lastCheckStatus в domainResponse
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:
@@ -59,12 +59,13 @@ type domainResponse struct {
|
|||||||
ZoneName string `json:"zoneName"`
|
ZoneName string `json:"zoneName"`
|
||||||
ZoneID string `json:"zoneId"`
|
ZoneID string `json:"zoneId"`
|
||||||
TemplateID *string `json:"templateId,omitempty"`
|
TemplateID *string `json:"templateId,omitempty"`
|
||||||
|
LastCheckStatus string `json:"lastCheckStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toDomainResponse(d store.Domain) domainResponse {
|
func toDomainResponse(d store.Domain) domainResponse {
|
||||||
resp := domainResponse{
|
resp := domainResponse{
|
||||||
ID: d.ID.String(), ProviderAccountID: d.ProviderAccountID.String(),
|
ID: d.ID.String(), ProviderAccountID: d.ProviderAccountID.String(),
|
||||||
ZoneName: d.ZoneName, ZoneID: d.ZoneID,
|
ZoneName: d.ZoneName, ZoneID: d.ZoneID, LastCheckStatus: d.LastCheckStatus,
|
||||||
}
|
}
|
||||||
if d.TemplateID != nil {
|
if d.TemplateID != nil {
|
||||||
s := d.TemplateID.String()
|
s := d.TemplateID.String()
|
||||||
|
|||||||
@@ -128,4 +128,72 @@ describe("api client", () => {
|
|||||||
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`)
|
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`)
|
||||||
expect((opts as RequestInit).method).toBe("PATCH")
|
expect((opts as RequestInit).method).toBe("PATCH")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("schedule", () => {
|
||||||
|
it("getSchedule(projectId) GETs /schedule", async () => {
|
||||||
|
const spy = mockFetch({ intervalSeconds: 3600, enabled: false })
|
||||||
|
await api.getSchedule(PROJECT_ID)
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${PROJECT_ID}/schedule`,
|
||||||
|
expect.objectContaining({ method: "GET", credentials: "include" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("putSchedule(projectId, {intervalSeconds,enabled}) PUTs /schedule", async () => {
|
||||||
|
const spy = mockFetch({ intervalSeconds: 120, enabled: true })
|
||||||
|
await api.putSchedule(PROJECT_ID, { intervalSeconds: 120, enabled: true })
|
||||||
|
const [url, opts] = spy.mock.calls[0]
|
||||||
|
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/schedule`)
|
||||||
|
expect((opts as RequestInit).method).toBe("PUT")
|
||||||
|
expect((opts as RequestInit).credentials).toBe("include")
|
||||||
|
expect(String((opts as RequestInit).body)).toContain("intervalSeconds")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("channels", () => {
|
||||||
|
it("listChannels(projectId) GETs /channels", async () => {
|
||||||
|
const spy = mockFetch([])
|
||||||
|
await api.listChannels(PROJECT_ID)
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${PROJECT_ID}/channels`,
|
||||||
|
expect.objectContaining({ method: "GET" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("createChannel(projectId, {type,config,secret}) POSTs /channels with secret in body", async () => {
|
||||||
|
const spy = mockFetch({ id: "c1", type: "telegram", config: { chat_id: "1" }, enabled: true })
|
||||||
|
await api.createChannel(PROJECT_ID, { type: "telegram", config: { chat_id: "1" }, secret: "BOT_TOKEN" })
|
||||||
|
const [url, opts] = spy.mock.calls[0]
|
||||||
|
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/channels`)
|
||||||
|
expect((opts as RequestInit).method).toBe("POST")
|
||||||
|
expect(String((opts as RequestInit).body)).toContain("BOT_TOKEN")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("deleteChannel(projectId, id) DELETEs /channels/{id}", async () => {
|
||||||
|
const spy = mockFetch(undefined, true, 204)
|
||||||
|
await api.deleteChannel(PROJECT_ID, "c1")
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${PROJECT_ID}/channels/c1`,
|
||||||
|
expect.objectContaining({ method: "DELETE" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("testChannel(projectId, id) POSTs /channels/{id}/test", async () => {
|
||||||
|
const spy = mockFetch({ status: "ok" })
|
||||||
|
await api.testChannel(PROJECT_ID, "c1")
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${PROJECT_ID}/channels/c1/test`,
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("domainHistory(projectId, domainId) GETs /domains/{did}/history", async () => {
|
||||||
|
const spy = mockFetch([])
|
||||||
|
await api.domainHistory(PROJECT_ID, "d1")
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${PROJECT_ID}/domains/d1/history`,
|
||||||
|
expect.objectContaining({ method: "GET", credentials: "include" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +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,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export class UnauthorizedError extends Error {
|
export class UnauthorizedError extends Error {
|
||||||
@@ -77,4 +78,18 @@ 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) }),
|
||||||
|
domainHistory: (projectId: string, id: string) =>
|
||||||
|
req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)),
|
||||||
|
|
||||||
|
getSchedule: (projectId: string) => req<Schedule>(projectPath(projectId, "/schedule")),
|
||||||
|
putSchedule: (projectId: string, input: Schedule) =>
|
||||||
|
req<Schedule>(projectPath(projectId, "/schedule"), { method: "PUT", body: JSON.stringify(input) }),
|
||||||
|
|
||||||
|
listChannels: (projectId: string) => req<Channel[]>(projectPath(projectId, "/channels")),
|
||||||
|
createChannel: (projectId: string, input: CreateChannelInput) =>
|
||||||
|
req<Channel>(projectPath(projectId, "/channels"), { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
deleteChannel: (projectId: string, id: string) =>
|
||||||
|
req<void>(projectPath(projectId, `/channels/${id}`), { method: "DELETE" }),
|
||||||
|
testChannel: (projectId: string, id: string) =>
|
||||||
|
req<{ status: string }>(projectPath(projectId, `/channels/${id}/test`), { method: "POST" }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Domain {
|
|||||||
zoneName: string
|
zoneName: string
|
||||||
zoneId: string
|
zoneId: string
|
||||||
templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null
|
templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null
|
||||||
|
lastCheckStatus?: string // "unknown" | "in_sync" | "drift" | "error"
|
||||||
}
|
}
|
||||||
export interface CreateDomainInput {
|
export interface CreateDomainInput {
|
||||||
providerAccountId: string
|
providerAccountId: string
|
||||||
@@ -23,6 +24,13 @@ export interface CreateDomainInput {
|
|||||||
templateId?: string | null
|
templateId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Schedule { intervalSeconds: number; enabled: boolean }
|
||||||
|
|
||||||
|
export interface Channel { id: string; type: string; config: object; enabled: boolean }
|
||||||
|
export interface CreateChannelInput { type: string; config: object; secret: string }
|
||||||
|
|
||||||
|
export interface CheckRun { id?: string; createdAt: string; result: object }
|
||||||
|
|
||||||
export interface RecordView {
|
export interface RecordView {
|
||||||
kind: string // add | update | delete | in_sync
|
kind: string // add | update | delete | in_sync
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
+69
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { api } from "@/api/client"
|
import { api } from "@/api/client"
|
||||||
import { useAuth } from "@/auth/AuthContext"
|
import { useAuth } from "@/auth/AuthContext"
|
||||||
import type { CreateAccountInput, CreateTemplateInput, ApplyRequest, Project } from "@/api/types"
|
import type { CreateAccountInput, CreateTemplateInput, ApplyRequest, Project, Schedule, CreateChannelInput } from "@/api/types"
|
||||||
|
|
||||||
function requireProjectId(project: Project | null): string {
|
function requireProjectId(project: Project | null): string {
|
||||||
if (!project) throw new Error("no active project")
|
if (!project) throw new Error("no active project")
|
||||||
@@ -141,3 +141,71 @@ export function useApplyDomain(id: string) {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
export function useDomainHistory(id: string) {
|
||||||
|
const { project } = useAuth()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["domainHistory", project?.id, id],
|
||||||
|
queryFn: () => api.domainHistory(project!.id, id),
|
||||||
|
enabled: !!project && !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchedule() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["schedule", project?.id],
|
||||||
|
queryFn: () => api.getSchedule(project!.id),
|
||||||
|
enabled: !!project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useUpdateSchedule() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: Schedule) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.putSchedule(pid, input)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["schedule", project?.id] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChannels() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["channels", project?.id],
|
||||||
|
queryFn: () => api.listChannels(project!.id),
|
||||||
|
enabled: !!project,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useCreateChannel() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateChannelInput) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.createChannel(pid, input)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels", project?.id] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useDeleteChannel() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.deleteChannel(pid, id)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels", project?.id] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useTestChannel() {
|
||||||
|
const { project } = useAuth()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.testChannel(pid, id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user