feat(integrations): добавить ручной sync с CFDM
Docker / build (push) Failing after 22s

Кнопка в настройках запрашивает полную выгрузку bindings из CFDM.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-31 23:24:04 +07:00
co-authored by Cursor
parent 5bbf0061cf
commit 033557a859
9 changed files with 215 additions and 17 deletions
+19 -1
View File
@@ -27,7 +27,7 @@ describe('integrations CFDM routes', () => {
method: 'POST', method: 'POST',
url: '/api/integrations/cfdm/ping', url: '/api/integrations/cfdm/ping',
}) })
expect(res.statusCode).toBe(401) expect(res.statusCode).toBe(503)
}) })
it('принимает ping с верным Bearer', async () => { it('принимает ping с верным Bearer', async () => {
@@ -44,4 +44,22 @@ describe('integrations CFDM routes', () => {
expect(res.statusCode).toBe(200) expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true, service: 'vps-tracker' }) expect(res.json()).toEqual({ ok: true, service: 'vps-tracker' })
}) })
it('принимает fullSync с пустым списком bindings', async () => {
settingsRepository.upsert('settings-main', {
integrationToken: 'test-secret',
integrationEnabled: true,
})
const res = await app.inject({
method: 'POST',
url: '/api/integrations/cfdm/sync-bindings',
headers: {
authorization: 'Bearer test-secret',
'content-type': 'application/json',
},
payload: { bindings: [], fullSync: true },
})
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ ok: true, upserted: 0 })
})
}) })
+3 -1
View File
@@ -27,7 +27,9 @@ export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
} }
return runInIntegrationSpace(req, () => { return runInIntegrationSpace(req, () => {
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings) const result = vpsDomainsRepository.syncBindings(parsed.data.bindings, {
fullSync: parsed.data.fullSync === true,
})
settingsRepository.touchIntegrationSync() settingsRepository.touchIntegrationSync()
return { return {
ok: true, ok: true,
+46
View File
@@ -81,3 +81,49 @@ describe('settings telegram test', () => {
expect(url).toContain('botoverride-token/') expect(url).toContain('botoverride-token/')
}) })
}) })
describe('settings cfdm sync', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
settingsRepository.upsert('settings-main', {
integrationEnabled: true,
integrationToken: 'shared-token',
cfdmApiUrl: 'http://cfdm.test',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
vi.unstubAllGlobals()
closeDb()
})
it('çàïðàøèâàåò ïîëíûé sync ó CFDM', async () => {
const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 3 }))
vi.stubGlobal('fetch', fetchMock)
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true, count: 3 })
const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined
expect(call?.[0]).toBe('http://cfdm.test/api/v1/integrations/vps-tracker/sync')
expect((call?.[1].headers as Record<string, string>).Authorization).toBe(
'Bearer shared-token',
)
})
it('âîçâðàùàåò îøèáêó åñëè ïðè¸ì âûêëþ÷åí', async () => {
settingsRepository.upsert('settings-main', {
integrationEnabled: false,
integrationToken: 'shared-token',
cfdmApiUrl: 'http://cfdm.test',
})
const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' })
expect(res.statusCode).toBe(502)
expect(res.json()).toMatchObject({ ok: false })
})
})
+13
View File
@@ -6,6 +6,7 @@ import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/s
import { restartScheduler } from '../services/scheduler.js' import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js' import { sendTelegramMessage } from '../services/telegram.js'
import { deliverWebhook } from '../services/notifications/channels.js' import { deliverWebhook } from '../services/notifications/channels.js'
import { requestCfdmFullSync } from '../services/cfdm-sync.js'
import { requireSpaceRole } from '../plugins/space.js' import { requireSpaceRole } from '../plugins/space.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => { export const settingsRoutes: FastifyPluginAsync = async (app) => {
@@ -80,4 +81,16 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
}) })
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' } return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' }
}) })
app.post('/api/settings/cfdm/sync', async (req, reply) => {
if (!requireSpaceRole(req, reply, 'admin')) return
const result = await requestCfdmFullSync()
if (!result.ok) {
return reply.code(502).send({
ok: false,
error: result.error ?? 'Не удалось синхронизировать с CFDM',
})
}
return { ok: true, count: result.count ?? 0 }
})
} }
+50
View File
@@ -0,0 +1,50 @@
import { settingsRepository } from '@cfdm/db/repositories/settings'
function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getBySpace()
if (!row) return null
const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '')
const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm')
return cfdm?.url?.trim().replace(/\/$/, '') ?? null
}
export async function requestCfdmFullSync(): Promise<{
ok: boolean
count?: number
error?: string
}> {
const row = settingsRepository.getBySpace()
if (!row?.integrationEnabled) {
return { ok: false, error: 'Включите приём синхронизации' }
}
const token = settingsRepository.getIntegrationToken()
const baseUrl = resolveCfdmApiBase()
if (!baseUrl) return { ok: false, error: 'Укажите URL API CFDM' }
if (!token) return { ok: false, error: 'Укажите integration token' }
try {
const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const body = (await res.json().catch(() => ({}))) as {
ok?: boolean
count?: number
error?: string
}
if (!res.ok || body.ok === false) {
return {
ok: false,
error: body.error ?? `HTTP ${res.status}`,
}
}
return { ok: true, count: body.count ?? 0 }
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Ошибка сети',
}
}
}
@@ -2,6 +2,8 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { RefreshCwIcon } from 'lucide-react'
import { SettingRow } from '@/components/setting-row' import { SettingRow } from '@/components/setting-row'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
@@ -9,6 +11,8 @@ import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input' import { Input } from '@cfdm/ui/components/input'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { api, ApiError } from '@/lib/api-client'
import { snapshotQueryOptions } from '@/queries/snapshot'
import type { Settings } from '@/types/entities' import type { Settings } from '@/types/entities'
const formSchema = z.object({ const formSchema = z.object({
@@ -35,12 +39,13 @@ interface CfdmIntegrationFormProps {
isSaving?: boolean isSaving?: boolean
} }
/** CFDM integration form — Frame/SettingRow, no Card. Preview https://reui.io/preview/base/settings-3 */ /** CFDM integration form — Frame/SettingRow. Preview https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-16 */
export function CfdmIntegrationForm({ export function CfdmIntegrationForm({
settings, settings,
onSave, onSave,
isSaving, isSaving,
}: CfdmIntegrationFormProps) { }: CfdmIntegrationFormProps) {
const queryClient = useQueryClient()
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: { values: {
@@ -50,6 +55,21 @@ export function CfdmIntegrationForm({
}, },
}) })
const syncMut = useMutation({
mutationFn: () => api.syncCfdm(),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
toast.success(
result.count != null
? `Синхронизация завершена: ${result.count} bindings`
: 'Синхронизация завершена',
)
},
onError: (e: unknown) => {
toast.error(e instanceof ApiError ? e.message : 'Не удалось синхронизировать с CFDM')
},
})
function handleSubmit(values: FormValues) { function handleSubmit(values: FormValues) {
const token = values.integrationToken?.trim() const token = values.integrationToken?.trim()
onSave({ onSave({
@@ -59,6 +79,11 @@ export function CfdmIntegrationForm({
}) })
} }
const canSync =
settings?.integrationEnabled === true &&
Boolean(settings?.cfdmApiUrl?.trim()) &&
settings?.integrationTokenSet === true
return ( return (
<form <form
className="flex flex-col gap-0" className="flex flex-col gap-0"
@@ -83,7 +108,7 @@ export function CfdmIntegrationForm({
</SettingRow> </SettingRow>
<SettingRow <SettingRow
title="URL API CFDM" title="URL API CFDM"
description="Для failover vps_down" description="Для failover vps_down и ручного sync"
labelFor="cfdm-api-url" labelFor="cfdm-api-url"
stacked stacked
> >
@@ -103,7 +128,6 @@ export function CfdmIntegrationForm({
} }
labelFor="integration-token" labelFor="integration-token"
stacked stacked
last={!settings?.integrationLastSyncAt}
> >
<div className="flex w-full flex-col gap-2 sm:flex-row"> <div className="flex w-full flex-col gap-2 sm:flex-row">
<Input <Input
@@ -134,17 +158,27 @@ export function CfdmIntegrationForm({
</Button> </Button>
</div> </div>
</SettingRow> </SettingRow>
{settings?.integrationLastSyncAt ? (
<SettingRow <SettingRow
title="Последний sync" title="Синхронизация"
description={new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')} description={
settings?.integrationLastSyncAt
? `Последний sync: ${new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}`
: 'Запросить полную выгрузку доменов и сервисов из CFDM'
}
last last
> >
<span className="text-muted-foreground text-sm tabular-nums"> <LoadingButton
{new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')} type="button"
</span> variant="outline"
size="sm"
loading={syncMut.isPending}
disabled={!canSync || form.formState.isDirty}
onClick={() => syncMut.mutate()}
>
<RefreshCwIcon data-icon="inline-start" aria-hidden="true" />
Синхронизировать
</LoadingButton>
</SettingRow> </SettingRow>
) : null}
</FieldGroup> </FieldGroup>
<div className="flex justify-end border-t px-5 py-3"> <div className="flex justify-end border-t px-5 py-3">
<LoadingButton <LoadingButton
+5
View File
@@ -162,6 +162,11 @@ export const api = {
sendWebhookTest: () => sendWebhookTest: () =>
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }), fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
syncCfdm: () =>
fetchApi<{ ok: boolean; count?: number; error?: string }>('/api/settings/cfdm/sync', {
method: 'POST',
}),
fetchNotificationLog: (limit = 50) => fetchNotificationLog: (limit = 50) =>
fetchApi<import('@/types/entities').NotificationLogRow[]>( fetchApi<import('@/types/entities').NotificationLogRow[]>(
`/api/notifications/log?limit=${limit}`, `/api/notifications/log?limit=${limit}`,
+26 -1
View File
@@ -125,7 +125,10 @@ export const vpsDomainsRepository = {
return { updated } return { updated }
}, },
syncBindings(items: CfdmBindingSyncItem[]): { syncBindings(
items: CfdmBindingSyncItem[],
opts?: { fullSync?: boolean },
): {
matched: number matched: number
unmatched: number unmatched: number
deleted: number deleted: number
@@ -139,6 +142,7 @@ export const vpsDomainsRepository = {
let unmatched = 0 let unmatched = 0
let deleted = 0 let deleted = 0
let upserted = 0 let upserted = 0
const keptBindingIds = new Set<number>()
for (const item of items) { for (const item of items) {
if (item.deleted) { if (item.deleted) {
@@ -146,6 +150,8 @@ export const vpsDomainsRepository = {
continue continue
} }
keptBindingIds.add(item.bindingId)
const vpsId = findVpsIdByIps(allVps, item.ips) const vpsId = findVpsIdByIps(allVps, item.ips)
const matchStatus = resolveMatchStatus(vpsId) const matchStatus = resolveMatchStatus(vpsId)
if (matchStatus === 'matched') matched++ if (matchStatus === 'matched') matched++
@@ -176,6 +182,25 @@ export const vpsDomainsRepository = {
upserted++ upserted++
} }
if (opts?.fullSync) {
const rows = db
.select()
.from(schema.vpsDomains)
.where(
and(
eq(schema.vpsDomains.spaceId, spaceId),
eq(schema.vpsDomains.source, 'cfdm'),
),
)
.all()
for (const row of rows) {
if (!keptBindingIds.has(row.cfdmBindingId)) {
db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
deleted++
}
}
}
return { matched, unmatched, deleted, upserted } return { matched, unmatched, deleted, upserted }
}, },
@@ -13,7 +13,12 @@ export const cfdmBindingSyncItemSchema = z.object({
}) })
export const cfdmSyncBindingsBodySchema = z.object({ export const cfdmSyncBindingsBodySchema = z.object({
bindings: z.array(cfdmBindingSyncItemSchema).min(1), bindings: z.array(cfdmBindingSyncItemSchema),
/** Полная пересинхронизация: удалить CFDM-домены, которых нет в payload. */
fullSync: z.boolean().optional(),
}).refine((data) => data.fullSync === true || data.bindings.length >= 1, {
message: 'bindings обязателен, если fullSync не задан',
path: ['bindings'],
}) })
export type CfdmBindingSyncItem = z.infer<typeof cfdmBindingSyncItemSchema> export type CfdmBindingSyncItem = z.infer<typeof cfdmBindingSyncItemSchema>