diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts index 932304b..ba5d59f 100644 --- a/apps/api/src/routes/settings.test.ts +++ b/apps/api/src/routes/settings.test.ts @@ -115,12 +115,31 @@ describe('settings cfdm sync', () => { }) it('requests full sync from CFDM', async () => { - const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 3 })) + const fetchMock = vi.fn(async () => + Response.json({ + ok: true, + count: 1, + fullSync: true, + bindings: [ + { + bindingId: 1, + serviceId: 10, + serviceName: 'web', + serviceSlug: 'web', + fqdn: 'app.example.com', + zoneName: 'example.com', + hostname: 'app', + ips: ['1.2.3.4'], + }, + ], + }), + ) 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 }) + expect(res.json()).toMatchObject({ ok: true }) + expect((res.json() as { count: number }).count).toBeGreaterThanOrEqual(1) const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined expect(call?.[0]).toBe('http://cfdm.test/api/v1/integrations/vps-tracker/sync') @@ -135,20 +154,53 @@ describe('settings cfdm sync', () => { integrationToken: 'shared-token', cfdmApiUrl: 'http://cfdm.test', }) - const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 1 })) + 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(200) - expect(res.json()).toEqual({ ok: true, count: 1 }) + expect(res.json()).toEqual({ ok: true, count: 0 }) expect(fetchMock).toHaveBeenCalled() }) + it('returns clear error when CFDM is unreachable', async () => { + settingsRepository.upsert('settings-main', { + integrationEnabled: true, + integrationToken: 'shared-token', + cfdmApiUrl: 'http://cfdm.test', + }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed') + }), + ) + + 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('http://cfdm.test') + expect(body.error).toContain('fetch failed') + }) + it('returns error when CFDM URL is missing', async () => { settingsRepository.upsert('settings-main', { integrationEnabled: true, integrationToken: 'shared-token', cfdmApiUrl: '', + appSwitcher: { + menuLabel: 'Apps', + apps: [ + { + id: 'vps-tracker', + name: 'VPS Tracker', + url: 'http://127.0.0.1:3001', + }, + ], + }, }) const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) expect(res.statusCode).toBe(502) diff --git a/apps/api/src/services/cfdm-sync.ts b/apps/api/src/services/cfdm-sync.ts index be7f413..87123fa 100644 --- a/apps/api/src/services/cfdm-sync.ts +++ b/apps/api/src/services/cfdm-sync.ts @@ -1,12 +1,39 @@ import { settingsRepository } from '@cfdm/db/repositories/settings' +import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains' +import { + cfdmSyncBindingsBodySchema, + type CfdmBindingSyncItem, +} from '@cfdm/shared/contracts/integration-cfdm' + +function isUsableHttpUrl(raw: string): boolean { + try { + const u = new URL(raw) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false + const host = u.hostname.toLowerCase() + if (host === 'example.com' || host.endsWith('.example.com')) return false + return true + } catch { + return false + } +} function resolveCfdmApiBase(): string | null { const row = settingsRepository.getBySpace() if (!row) return null const explicit = row.cfdmApiUrl?.trim() - if (explicit) return explicit.replace(/\/$/, '') + if (explicit && isUsableHttpUrl(explicit)) return explicit.replace(/\/$/, '') const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm') - return cfdm?.url?.trim().replace(/\/$/, '') ?? null + const fromSwitcher = cfdm?.url?.trim().replace(/\/$/, '') ?? '' + if (fromSwitcher && isUsableHttpUrl(fromSwitcher)) return fromSwitcher + return null +} + +function networkErrorMessage(baseUrl: string, err: unknown): string { + const raw = err instanceof Error ? err.message : 'Ошибка сети' + return ( + `Не удалось подключиться к CFDM (${baseUrl}): ${raw}. ` + + 'Укажите URL API CFDM (доступный с хоста VPS Tracker API) и сохраните.' + ) } export async function requestCfdmFullSync(): Promise<{ @@ -16,30 +43,67 @@ export async function requestCfdmFullSync(): Promise<{ }> { const token = settingsRepository.getIntegrationToken() const baseUrl = resolveCfdmApiBase() - if (!baseUrl) return { ok: false, error: 'Укажите URL API CFDM' } + if (!baseUrl) { + return { + ok: false, + error: + 'Укажите URL API CFDM в настройках интеграции (или добавьте приложение cfdm в App Switcher)', + } + } if (!token) return { ok: false, error: 'Укажите integration token' } + const syncUrl = `${baseUrl}/api/v1/integrations/vps-tracker/sync` + try { - const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/sync`, { + const res = await fetch(syncUrl, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(60_000), }) const body = (await res.json().catch(() => ({}))) as { ok?: boolean count?: number error?: string + bindings?: CfdmBindingSyncItem[] + fullSync?: boolean } + if (!res.ok || body.ok === false) { return { ok: false, - error: body.error ?? `HTTP ${res.status}`, + error: body.error ?? `CFDM HTTP ${res.status} (${syncUrl})`, } } + + // Pull: CFDM отдаёт bindings в ответе — применяем локально (без обратного push). + if (Array.isArray(body.bindings)) { + const parsed = cfdmSyncBindingsBodySchema.safeParse({ + bindings: body.bindings, + fullSync: body.fullSync !== false, + }) + if (!parsed.success) { + return { + ok: false, + error: `Некорректный ответ CFDM: ${parsed.error.message}`, + } + } + const applied = vpsDomainsRepository.syncBindings(parsed.data.bindings, { + fullSync: parsed.data.fullSync === true, + }) + settingsRepository.touchIntegrationSync() + return { + ok: true, + count: parsed.data.bindings.length || applied.upserted, + } + } + + // Legacy: CFDM уже запушил bindings сам и вернул только count. + settingsRepository.touchIntegrationSync() return { ok: true, count: body.count ?? 0 } } catch (err) { return { ok: false, - error: err instanceof Error ? err.message : 'Ошибка сети', + error: networkErrorMessage(baseUrl, err), } } } diff --git a/apps/web/src/components/integrations/cfdm-integration-card.tsx b/apps/web/src/components/integrations/cfdm-integration-card.tsx index 93b9067..67d8ce5 100644 --- a/apps/web/src/components/integrations/cfdm-integration-card.tsx +++ b/apps/web/src/components/integrations/cfdm-integration-card.tsx @@ -31,6 +31,8 @@ function generateToken(): string { interface CfdmIntegrationFormProps { settings?: Settings + /** URL CFDM из App Switcher — подсказка и fallback, если cfdmApiUrl не сохранён */ + fallbackCfdmUrl?: string onSave: (values: { cfdmApiUrl?: string integrationToken?: string @@ -42,6 +44,7 @@ interface CfdmIntegrationFormProps { /** CFDM integration form — Frame/SettingRow. Preview https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-16 */ export function CfdmIntegrationForm({ settings, + fallbackCfdmUrl, onSave, isSaving, }: CfdmIntegrationFormProps) { @@ -79,9 +82,9 @@ export function CfdmIntegrationForm({ }) } - // Sync по сохранённому токену (URL — cfdmApiUrl или App Switcher на API). const hasSavedToken = Boolean(settings?.integrationTokenSet) const hasSavedUrl = Boolean(settings?.cfdmApiUrl?.trim()) + const switcherUrl = fallbackCfdmUrl?.trim() || '' const canSync = hasSavedToken || Boolean(settings?.integrationLastSyncAt?.trim()) @@ -93,7 +96,7 @@ export function CfdmIntegrationForm({ diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx index 8ab0c11..9e6cad3 100644 --- a/apps/web/src/routes/_auth/settings/integrations.tsx +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -256,6 +256,9 @@ function SettingsIntegrationsPage() { > a.id === 'cfdm')?.url + } isSaving={saveMut.isPending} onSave={(values) => saveMut.mutate(values)} />