Кнопка в настройках запрашивает полную выгрузку bindings из CFDM. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user