diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go index b3dd1a2..613b1c8 100644 --- a/internal/api/tenant_dto.go +++ b/internal/api/tenant_dto.go @@ -59,12 +59,13 @@ type domainResponse struct { ZoneName string `json:"zoneName"` ZoneID string `json:"zoneId"` TemplateID *string `json:"templateId,omitempty"` + LastCheckStatus string `json:"lastCheckStatus"` } func toDomainResponse(d store.Domain) domainResponse { resp := domainResponse{ 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 { s := d.TemplateID.String() diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 43a966f..f9ad71d 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -128,4 +128,72 @@ describe("api client", () => { expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`) 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" }), + ) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 748581d..a55bc2e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,6 +3,7 @@ import type { AuthState, Account, CreateAccountInput, Template, CreateTemplateInput, Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, + Schedule, Channel, CreateChannelInput, CheckRun, } from "./types" export class UnauthorizedError extends Error { @@ -77,4 +78,18 @@ export const api = { req(projectPath(projectId, `/domains/${id}/check`)), applyDomain: (projectId: string, id: string, body: ApplyRequest) => req(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }), + domainHistory: (projectId: string, id: string) => + req(projectPath(projectId, `/domains/${id}/history`)), + + getSchedule: (projectId: string) => req(projectPath(projectId, "/schedule")), + putSchedule: (projectId: string, input: Schedule) => + req(projectPath(projectId, "/schedule"), { method: "PUT", body: JSON.stringify(input) }), + + listChannels: (projectId: string) => req(projectPath(projectId, "/channels")), + createChannel: (projectId: string, input: CreateChannelInput) => + req(projectPath(projectId, "/channels"), { method: "POST", body: JSON.stringify(input) }), + deleteChannel: (projectId: string, id: string) => + req(projectPath(projectId, `/channels/${id}`), { method: "DELETE" }), + testChannel: (projectId: string, id: string) => + req<{ status: string }>(projectPath(projectId, `/channels/${id}/test`), { method: "POST" }), } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7546eeb..da63682 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,6 +15,7 @@ export interface Domain { zoneName: string zoneId: string templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null + lastCheckStatus?: string // "unknown" | "in_sync" | "drift" | "error" } export interface CreateDomainInput { providerAccountId: string @@ -23,6 +24,13 @@ export interface CreateDomainInput { 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 { kind: string // add | update | delete | in_sync type: string diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 2c8b3cd..7a7afd2 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "@/api/client" 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 { 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] }), }) } +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) + }, + }) +}