From d4d59f4b8e9e4d4b3f21fa5da4e6dd1538d724af Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 1 Aug 2026 00:46:28 +0700 Subject: [PATCH] =?UTF-8?q?fix(integrations):=20sync=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=20cfdmApiUrl=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убран fallback на локальный App Switcher (192.168.x). URL из portal подставляется в форму для сохранения; кнопка sync требует URL в БД. Co-authored-by: Cursor --- apps/api/src/routes/settings.test.ts | 31 +++++++++--- apps/api/src/services/cfdm-notify.ts | 8 ++- apps/api/src/services/cfdm-sync.ts | 25 +++++----- .../integrations/cfdm-integration-card.tsx | 49 +++++++++++++------ .../routes/_auth/settings/integrations.tsx | 6 +-- 5 files changed, 76 insertions(+), 43 deletions(-) diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts index ba5d59f..1c964fc 100644 --- a/apps/api/src/routes/settings.test.ts +++ b/apps/api/src/routes/settings.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { closeDb } from '@cfdm/db' import { settingsRepository } from '@cfdm/db/repositories/settings' import { resetTestDb } from '@cfdm/db/test-setup' @@ -191,19 +191,38 @@ describe('settings cfdm sync', () => { integrationEnabled: true, integrationToken: 'shared-token', cfdmApiUrl: '', + }) + const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) + expect(res.statusCode).toBe(502) + const body = res.json() as { ok: boolean; error: string } + expect(body.ok).toBe(false) + expect(body.error).toContain('URL API CFDM') + }) + + it('uses only saved cfdmApiUrl, not App Switcher defaults', async () => { + settingsRepository.upsert('settings-main', { + integrationEnabled: true, + integrationToken: 'shared-token', + cfdmApiUrl: 'https://cfdm.prod.example', appSwitcher: { menuLabel: 'Apps', apps: [ { - id: 'vps-tracker', - name: 'VPS Tracker', - url: 'http://127.0.0.1:3001', + id: 'cfdm', + name: 'CFDM', + url: 'http://192.168.100.67:6363', }, ], }, }) + const fetchMock = vi.fn(async () => + Response.json({ ok: true, count: 0, bindings: [], fullSync: true }), + ) + vi.stubGlobal('fetch', fetchMock) + const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) - expect(res.statusCode).toBe(502) - expect(res.json()).toMatchObject({ ok: false }) + expect(res.statusCode).toBe(200) + const call = fetchMock.mock.calls[0] as [string] | undefined + expect(call?.[0]).toBe('https://cfdm.prod.example/api/v1/integrations/vps-tracker/sync') }) }) diff --git a/apps/api/src/services/cfdm-notify.ts b/apps/api/src/services/cfdm-notify.ts index d10eada..d0e013f 100644 --- a/apps/api/src/services/cfdm-notify.ts +++ b/apps/api/src/services/cfdm-notify.ts @@ -4,11 +4,9 @@ import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm' 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 + const explicit = row?.cfdmApiUrl?.trim() ?? '' + if (!explicit) return null + return explicit.replace(/\/$/, '') } export async function notifyCfdmVpsEvent( diff --git a/apps/api/src/services/cfdm-sync.ts b/apps/api/src/services/cfdm-sync.ts index 87123fa..dcb5471 100644 --- a/apps/api/src/services/cfdm-sync.ts +++ b/apps/api/src/services/cfdm-sync.ts @@ -17,22 +17,20 @@ function isUsableHttpUrl(raw: string): boolean { } } +/** Только явно сохранённый settings.cfdmApiUrl — без App Switcher / локальных дефолтов. */ function resolveCfdmApiBase(): string | null { const row = settingsRepository.getBySpace() - if (!row) return null - const explicit = row.cfdmApiUrl?.trim() - if (explicit && isUsableHttpUrl(explicit)) return explicit.replace(/\/$/, '') - const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm') - const fromSwitcher = cfdm?.url?.trim().replace(/\/$/, '') ?? '' - if (fromSwitcher && isUsableHttpUrl(fromSwitcher)) return fromSwitcher - return null + const explicit = row?.cfdmApiUrl?.trim() ?? '' + if (!explicit) return null + if (!isUsableHttpUrl(explicit)) return null + return explicit.replace(/\/$/, '') } function networkErrorMessage(baseUrl: string, err: unknown): string { const raw = err instanceof Error ? err.message : 'Ошибка сети' return ( `Не удалось подключиться к CFDM (${baseUrl}): ${raw}. ` + - 'Укажите URL API CFDM (доступный с хоста VPS Tracker API) и сохраните.' + 'Проверьте, что сохранённый URL API доступен с хоста, где запущен VPS Tracker API.' ) } @@ -47,10 +45,15 @@ export async function requestCfdmFullSync(): Promise<{ return { ok: false, error: - 'Укажите URL API CFDM в настройках интеграции (или добавьте приложение cfdm в App Switcher)', + 'URL API CFDM не сохранён в настройках интеграции. Укажите продовый URL и нажмите «Сохранить интеграцию».', + } + } + if (!token) { + return { + ok: false, + error: 'Integration token не сохранён. Сгенерируйте/вставьте токен и сохраните интеграцию.', } } - if (!token) return { ok: false, error: 'Укажите integration token' } const syncUrl = `${baseUrl}/api/v1/integrations/vps-tracker/sync` @@ -75,7 +78,6 @@ export async function requestCfdmFullSync(): Promise<{ } } - // Pull: CFDM отдаёт bindings в ответе — применяем локально (без обратного push). if (Array.isArray(body.bindings)) { const parsed = cfdmSyncBindingsBodySchema.safeParse({ bindings: body.bindings, @@ -97,7 +99,6 @@ export async function requestCfdmFullSync(): Promise<{ } } - // Legacy: CFDM уже запушил bindings сам и вернул только count. settingsRepository.touchIntegrationSync() return { ok: true, count: body.count ?? 0 } } catch (err) { diff --git a/apps/web/src/components/integrations/cfdm-integration-card.tsx b/apps/web/src/components/integrations/cfdm-integration-card.tsx index 67d8ce5..041aab7 100644 --- a/apps/web/src/components/integrations/cfdm-integration-card.tsx +++ b/apps/web/src/components/integrations/cfdm-integration-card.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -31,7 +32,7 @@ function generateToken(): string { interface CfdmIntegrationFormProps { settings?: Settings - /** URL CFDM из App Switcher — подсказка и fallback, если cfdmApiUrl не сохранён */ + /** URL CFDM из App Switcher (portal) — только подсказка для заполнения поля */ fallbackCfdmUrl?: string onSave: (values: { cfdmApiUrl?: string @@ -49,15 +50,25 @@ export function CfdmIntegrationForm({ isSaving, }: CfdmIntegrationFormProps) { const queryClient = useQueryClient() + const savedUrl = settings?.cfdmApiUrl?.trim() ?? '' + const switcherUrl = fallbackCfdmUrl?.trim() || '' + const form = useForm({ resolver: zodResolver(formSchema), values: { - cfdmApiUrl: settings?.cfdmApiUrl ?? '', + cfdmApiUrl: savedUrl, integrationToken: '', integrationEnabled: Boolean(settings?.integrationEnabled), }, }) + // Если URL ещё не сохранён в settings — подставить prod URL из App Switcher и включить «Сохранить». + useEffect(() => { + if (savedUrl || !switcherUrl) return + if (form.getValues('cfdmApiUrl')?.trim()) return + form.setValue('cfdmApiUrl', switcherUrl, { shouldDirty: true, shouldTouch: true }) + }, [savedUrl, switcherUrl, form]) + const syncMut = useMutation({ mutationFn: () => api.syncCfdm(), onSuccess: async (result) => { @@ -75,18 +86,18 @@ export function CfdmIntegrationForm({ function handleSubmit(values: FormValues) { const token = values.integrationToken?.trim() + const url = values.cfdmApiUrl?.trim() ?? '' onSave({ integrationEnabled: values.integrationEnabled, - cfdmApiUrl: values.cfdmApiUrl?.trim() || undefined, + // Всегда пишем явное значение — иначе URL не попадает в БД. + cfdmApiUrl: url, ...(token ? { integrationToken: token } : {}), }) } const hasSavedToken = Boolean(settings?.integrationTokenSet) - const hasSavedUrl = Boolean(settings?.cfdmApiUrl?.trim()) - const switcherUrl = fallbackCfdmUrl?.trim() || '' - const canSync = - hasSavedToken || Boolean(settings?.integrationLastSyncAt?.trim()) + const hasSavedUrl = Boolean(savedUrl) + const canSync = hasSavedToken && hasSavedUrl return (
@@ -173,7 +184,7 @@ export function CfdmIntegrationForm({ description={ settings?.integrationLastSyncAt ? `Последний sync: ${new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}` - : 'Запросить полную выгрузку доменов и сервисов из CFDM' + : 'Используются только сохранённые URL и токен' } last > @@ -185,11 +196,17 @@ export function CfdmIntegrationForm({ disabled={syncMut.isPending || !canSync} title={ canSync - ? 'Синхронизировать по сохранённым credentials' - : 'Сначала сохраните integration token' + ? `Синхронизировать с ${savedUrl}` + : !hasSavedUrl + ? 'Сначала сохраните URL API CFDM' + : 'Сначала сохраните integration token' } onClick={() => { - if (!canSync) { + if (!hasSavedUrl) { + toast.error('Сначала сохраните URL API CFDM (кнопка «Сохранить интеграцию»)') + return + } + if (!hasSavedToken) { toast.error('Сначала сохраните integration token') return } diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx index 9e6cad3..2ad792e 100644 --- a/apps/web/src/routes/_auth/settings/integrations.tsx +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -243,11 +243,9 @@ function SettingsIntegrationsPage() { id="cfdm" name="CF Domain Manager" description={ - current?.cfdmApiUrl + current?.cfdmApiUrl?.trim() ? current.cfdmApiUrl - : appSwitcher.apps.find((a) => a.id === 'cfdm')?.url - ? `URL из App Switcher · приём доменов и сервисов` - : 'Приём синхронизации доменов и сервисов' + : 'URL API не сохранён — укажите продовый домен и сохраните' } logo={