diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f3dcb0e..d1b8851 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,8 @@ import { migrateRoutes } from './routes/migrate.js' import { dashboardRoutes } from './routes/dashboard.js' import { auditRoutes } from './routes/audit.js' import { notificationsRoutes } from './routes/notifications.js' +import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js' +import { appSwitcherRoutes } from './routes/app-switcher.js' import { startScheduler } from './services/scheduler.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -58,6 +60,8 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(dashboardRoutes) await app.register(auditRoutes) await app.register(notificationsRoutes) + await app.register(integrationsCfdmRoutes) + await app.register(appSwitcherRoutes) const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist') if (existsSync(staticDir)) { diff --git a/apps/api/src/plugins/integration-auth.ts b/apps/api/src/plugins/integration-auth.ts new file mode 100644 index 0000000..7de4b56 --- /dev/null +++ b/apps/api/src/plugins/integration-auth.ts @@ -0,0 +1,43 @@ +import { timingSafeEqual } from 'node:crypto' +import type { FastifyReply, FastifyRequest } from 'fastify' +import { settingsRepository } from '@cfdm/db/repositories/settings' + +function safeEqualToken(expected: string, provided: string): boolean { + if (!expected || !provided) return false + const a = Buffer.from(expected) + const b = Buffer.from(provided) + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} + +function extractBearer(request: FastifyRequest): string { + const auth = request.headers.authorization ?? '' + if (auth.startsWith('Bearer ')) return auth.slice(7).trim() + return '' +} + +export async function requireIntegrationAuth( + request: FastifyRequest, + reply: FastifyReply, +): Promise { + const row = settingsRepository.getRow('settings-main') + if (!row?.integrationEnabled) { + return reply.code(403).send({ + error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' }, + }) + } + + const expected = settingsRepository.getIntegrationToken() + if (!expected) { + return reply.code(503).send({ + error: { code: 'INTEGRATION_NOT_CONFIGURED', message: 'Integration token не настроен' }, + }) + } + + const provided = extractBearer(request) + if (!safeEqualToken(expected, provided)) { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' }, + }) + } +} diff --git a/apps/api/src/routes/app-switcher.ts b/apps/api/src/routes/app-switcher.ts new file mode 100644 index 0000000..1ed36af --- /dev/null +++ b/apps/api/src/routes/app-switcher.ts @@ -0,0 +1,8 @@ +import type { FastifyPluginAsync } from 'fastify' +import { settingsRepository } from '@cfdm/db/repositories/settings' + +export const appSwitcherRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/settings/app-switcher', async () => { + return settingsRepository.getAppSwitcher() + }) +} diff --git a/apps/api/src/routes/integrations-cfdm.test.ts b/apps/api/src/routes/integrations-cfdm.test.ts new file mode 100644 index 0000000..e665665 --- /dev/null +++ b/apps/api/src/routes/integrations-cfdm.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb } from '@cfdm/db' +import { settingsRepository } from '@cfdm/db/repositories/settings' +import { resetTestDb } from '@cfdm/db/test-setup' +import { buildApp } from '../index.js' + +describe('integrations CFDM routes', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('отклоняет запрос без токена', async () => { + settingsRepository.upsert('settings-main', { + integrationToken: 'test-secret', + integrationEnabled: true, + }) + const app = await buildApp() + const res = await app.inject({ + method: 'POST', + url: '/api/integrations/cfdm/ping', + }) + expect(res.statusCode).toBe(401) + }) + + it('принимает ping с верным Bearer', async () => { + settingsRepository.upsert('settings-main', { + integrationToken: 'test-secret', + integrationEnabled: true, + }) + const app = await buildApp() + const res = await app.inject({ + method: 'POST', + url: '/api/integrations/cfdm/ping', + headers: { authorization: 'Bearer test-secret' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true, service: 'vps-tracker' }) + }) +}) diff --git a/apps/api/src/routes/integrations-cfdm.ts b/apps/api/src/routes/integrations-cfdm.ts new file mode 100644 index 0000000..8c7e51c --- /dev/null +++ b/apps/api/src/routes/integrations-cfdm.ts @@ -0,0 +1,34 @@ +import type { FastifyPluginAsync } from 'fastify' +import { cfdmSyncBindingsBodySchema } from '@cfdm/shared/contracts/integration-cfdm' +import { settingsRepository } from '@cfdm/db/repositories/settings' +import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains' +import { requireIntegrationAuth } from '../plugins/integration-auth.js' + +export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => { + app.post( + '/api/integrations/cfdm/ping', + { onRequest: requireIntegrationAuth }, + async () => ({ ok: true, service: 'vps-tracker' }), + ) + + app.post( + '/api/integrations/cfdm/sync-bindings', + { onRequest: requireIntegrationAuth }, + async (req, reply) => { + const parsed = cfdmSyncBindingsBodySchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ + error: { code: 'VALIDATION', message: parsed.error.message }, + }) + } + + const result = vpsDomainsRepository.syncBindings(parsed.data.bindings) + settingsRepository.touchIntegrationSync() + + return { + ok: true, + ...result, + } + }, + ) +} diff --git a/apps/api/src/routes/vps.ts b/apps/api/src/routes/vps.ts index 24052d3..470e4b1 100644 --- a/apps/api/src/routes/vps.ts +++ b/apps/api/src/routes/vps.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from 'fastify' import { vpsRepository } from '@cfdm/db/repositories/vps' +import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains' import { vpsSchema } from '@cfdm/shared/contracts/vps' import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js' @@ -27,6 +28,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { if (!updated) { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) } + vpsDomainsRepository.rematchAll() auditUpdate('vps', req.params.id, parsed.data as Record) return updated }) @@ -63,4 +65,8 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => { } return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } }) }) + + app.get<{ Params: { id: string } }>('/api/vps/:id/domains', async (req) => { + return vpsDomainsRepository.listByVpsId(req.params.id) + }) } diff --git a/apps/api/src/services/cfdm-notify.ts b/apps/api/src/services/cfdm-notify.ts new file mode 100644 index 0000000..740b52a --- /dev/null +++ b/apps/api/src/services/cfdm-notify.ts @@ -0,0 +1,57 @@ +import { settingsRepository } from '@cfdm/db/repositories/settings' +import { vpsRepository } from '@cfdm/db/repositories/vps' +import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm' + +const SETTINGS_ID = 'settings-main' + +function resolveCfdmApiBase(): string | null { + const row = settingsRepository.getRow(SETTINGS_ID) + 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 notifyCfdmVpsEvent( + event: VpsTrackerEvent['event'], + vpsIds: string[], +): Promise { + if (vpsIds.length === 0) return + + const row = settingsRepository.getRow(SETTINGS_ID) + if (!row?.integrationEnabled) return + + const token = settingsRepository.getIntegrationToken() + const baseUrl = resolveCfdmApiBase() + if (!baseUrl || !token) return + + const payload: VpsTrackerEvent = { + event, + vps: vpsIds.map((id) => { + const vps = vpsRepository.get(id) + return { + id, + ip: vps?.ip ?? undefined, + label: vps?.dns || vps?.ip || id, + } + }), + timestamp: new Date().toISOString(), + } + + try { + const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + console.warn(`CFDM event notify failed (${res.status})`) + } + } catch (err) { + console.warn('CFDM event notify error:', err instanceof Error ? err.message : err) + } +} diff --git a/apps/api/src/services/scheduler.ts b/apps/api/src/services/scheduler.ts index ccfcaaf..ad93d95 100644 --- a/apps/api/src/services/scheduler.ts +++ b/apps/api/src/services/scheduler.ts @@ -5,6 +5,7 @@ import { settingsRepository } from '@cfdm/db/repositories/settings' import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js' import { runAccountSync } from './providers/sync-job.js' import { runVpsUptimeChecks } from './uptime-check.js' +import { notifyCfdmVpsEvent } from './cfdm-notify.js' import { publishMany, publishNotification } from './notifications/engine.js' import { buildLowBalanceNotification, @@ -154,6 +155,12 @@ export async function runScheduledUptimeChecks(): Promise { newlyUp.map((h) => ({ id: h.id, label: h.label })), ), ]) + if (newlyDown.length > 0) { + void notifyCfdmVpsEvent( + 'vps_down', + newlyDown.map((h) => h.id), + ) + } } catch (err) { console.warn('Uptime check error:', err instanceof Error ? err.message : err) } diff --git a/apps/web/.env.example b/apps/web/.env.example index ba56725..1104426 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,4 @@ # Frontend (Vite) # VITE_API_URL= -# Список приложений для sidebar switcher (JSON, опционально) -# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[{"id":"vps-tracker","name":"VPS Tracker","subtitle":"Учёт VPS","url":"http://192.168.100.67:3001","icon":"server"},{"id":"cfdm","name":"CF Domain Manager","subtitle":"Домены","url":"http://192.168.100.67:6363","icon":"cloud"},{"id":"grafana","name":"Grafana","url":"https://grafana.example.com","icon":"chart"}]} +# Публичные URL приложений и integration token — в UI: Настройки → Интеграции diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index 5667c25..f467c15 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -16,13 +16,13 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' import { APP_SWITCHER_ICONS, CURRENT_APP_ID, - getAppSwitcherConfig, getCurrentApp, } from '@/lib/app-switcher-config' +import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' export function AppSwitcher() { const { isMobile } = useSidebar() - const config = getAppSwitcherConfig() + const { config, isLoading } = useAppSwitcherConfig() const current = getCurrentApp(config) const CurrentIcon = APP_SWITCHER_ICONS[current.icon] @@ -58,7 +58,7 @@ export function AppSwitcher() { sideOffset={4} >
- {config.menuLabel} + {isLoading ? 'Загрузка…' : config.menuLabel}
{config.apps.map((app) => { const Icon = APP_SWITCHER_ICONS[app.icon] diff --git a/apps/web/src/components/integrations/app-switcher-editor.tsx b/apps/web/src/components/integrations/app-switcher-editor.tsx new file mode 100644 index 0000000..fea12bb --- /dev/null +++ b/apps/web/src/components/integrations/app-switcher-editor.tsx @@ -0,0 +1,116 @@ +import { useFieldArray, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { PlusIcon, Trash2Icon } from 'lucide-react' +import { z } from 'zod' +import { appSwitcherConfigSchema } from '@cfdm/shared/contracts/app-switcher' + +import { Button } from '@cfdm/ui/components/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { FieldGroup } from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { FormField } from '@/components/form-field' +import { SelectField } from '@/components/select-field' +import { LoadingButton } from '@/components/loading-button' +import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config' + +const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({ + value: icon, + label: icon, +})) + +const formSchema = z.object({ + menuLabel: z.string().min(1), + apps: appSwitcherConfigSchema.shape.apps, +}) + +export type AppSwitcherFormValues = z.infer + +interface AppSwitcherEditorProps { + defaultValues: AppSwitcherFormValues + onSave: (values: AppSwitcherFormValues) => void + isSaving?: boolean +} + +export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues, + }) + const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' }) + + return ( + + + Связанные приложения + URL для переключателя в sidebar и deep links + + +
void form.handleSubmit(onSave)(e)} + > + + + + + {fields.map((field, index) => ( +
+ + + + + + + + + + + + form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, { + shouldDirty: true, + }) + } + options={ICON_OPTIONS} + /> + +
+ +
+
+ ))} + +
+ + Сохранить приложения + +
+
+
+ ) +} diff --git a/apps/web/src/components/integrations/cfdm-integration-card.tsx b/apps/web/src/components/integrations/cfdm-integration-card.tsx new file mode 100644 index 0000000..7014f86 --- /dev/null +++ b/apps/web/src/components/integrations/cfdm-integration-card.tsx @@ -0,0 +1,134 @@ +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { toast } from 'sonner' + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { FieldGroup } from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { FormField } from '@/components/form-field' +import { SelectField } from '@/components/select-field' +import { LoadingButton } from '@/components/loading-button' +import { Button } from '@cfdm/ui/components/button' +import type { Settings } from '@/types/entities' + +const formSchema = z.object({ + cfdmApiUrl: z.string().optional().default(''), + integrationToken: z.string().optional().default(''), + integrationEnabled: z.boolean().default(false), +}) + +type FormValues = z.infer + +function generateToken(): string { + const bytes = new Uint8Array(24) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + +interface CfdmIntegrationCardProps { + settings?: Settings + onSave: (values: { + cfdmApiUrl?: string + integrationToken?: string + integrationEnabled: boolean + }) => void + isSaving?: boolean +} + +export function CfdmIntegrationCard({ settings, onSave, isSaving }: CfdmIntegrationCardProps) { + const form = useForm({ + resolver: zodResolver(formSchema), + values: { + cfdmApiUrl: settings?.cfdmApiUrl ?? '', + integrationToken: '', + integrationEnabled: settings?.integrationEnabled === true, + }, + }) + + function handleSubmit(values: FormValues) { + const token = values.integrationToken?.trim() + onSave({ + integrationEnabled: values.integrationEnabled, + cfdmApiUrl: values.cfdmApiUrl?.trim() || undefined, + ...(token ? { integrationToken: token } : {}), + }) + } + + return ( + + + CF Domain Manager + + Приём синхронизации доменов и сервисов из CFDM. Скопируйте токен в настройки CFDM. + + + +
void form.handleSubmit(handleSubmit)(e)}> + + + + + +
+ + +
+
+ ( + + field.onChange((v ?? 'on') === 'on')} + options={[ + { value: 'on', label: 'Вкл' }, + { value: 'off', label: 'Выкл' }, + ]} + /> + + )} + /> + {settings?.integrationLastSyncAt ? ( +

+ Последний sync: {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')} +

+ ) : null} +
+ + Сохранить интеграцию + +
+
+
+ ) +} diff --git a/apps/web/src/components/integrations/vps-domains-cell.tsx b/apps/web/src/components/integrations/vps-domains-cell.tsx new file mode 100644 index 0000000..377bbc8 --- /dev/null +++ b/apps/web/src/components/integrations/vps-domains-cell.tsx @@ -0,0 +1,66 @@ +import { ExternalLinkIcon } from 'lucide-react' +import { Link } from '@tanstack/react-router' + +import { Badge } from '@/components/reui/badge' +import { useAppUrl } from '@/hooks/use-app-switcher' +import type { VpsDomain } from '@/types/entities' + +interface VpsDomainsCellProps { + domains: VpsDomain[] +} + +export function VpsDomainsCell({ domains }: VpsDomainsCellProps) { + const cfdmUrl = useAppUrl('cfdm') + + if (domains.length === 0) return + + return ( +
+ {domains.slice(0, 3).map((d) => ( +
+ {d.fqdn} + {d.matchStatus !== 'matched' ? ( + + {d.matchStatus === 'orphaned' ? 'orphan' : 'unmatched'} + + ) : null} + {cfdmUrl ? ( + + + + ) : null} +
+ ))} + {domains.length > 3 ? ( + +{domains.length - 3} + ) : null} +
+ ) +} + +export function UnmatchedDomainsBanner({ domains }: { domains: VpsDomain[] }) { + const unmatched = domains.filter((d) => d.matchStatus === 'unmatched' || !d.vpsId) + if (unmatched.length === 0) return null + + return ( +
+

Домены без привязки к VPS: {unmatched.length}

+
    + {unmatched.slice(0, 5).map((d) => ( +
  • + {d.fqdn} ({d.serviceName}) +
  • + ))} +
+ + Настройки интеграции + +
+ ) +} diff --git a/apps/web/src/hooks/use-app-switcher.ts b/apps/web/src/hooks/use-app-switcher.ts new file mode 100644 index 0000000..ce7a0f1 --- /dev/null +++ b/apps/web/src/hooks/use-app-switcher.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher' +import { appSwitcherQueryOptions } from '@/queries/app-switcher' +import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config' + +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' + +export function useAppSwitcherConfig(): { + config: AppSwitcherConfig + isLoading: boolean +} { + const { data, isLoading } = useQuery(appSwitcherQueryOptions()) + return { + config: data ?? DEFAULT_APP_SWITCHER_CONFIG, + isLoading, + } +} + +export function useAppUrl(appId: string): string | undefined { + const { config } = useAppSwitcherConfig() + return getAppUrlFromConfig(appId, config) +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index ff62472..f4c6f39 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -73,6 +73,7 @@ function uid(): string { export const api = { fetchData: () => fetchApi('/api/data'), + get: (path: string) => fetchApi(`/api/${path.replace(/^\//, '')}`), fetchCollection: (name: CollectionName) => fetchApi(COLLECTION_PATHS[name]), create: (name: CollectionName, record: T) => diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index 6247f8d..434910f 100644 --- a/apps/web/src/lib/app-switcher-config.ts +++ b/apps/web/src/lib/app-switcher-config.ts @@ -79,6 +79,13 @@ export function getAppSwitcherConfig(): AppSwitcherConfig { return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER) } +export function getAppUrl( + appId: string, + config: AppSwitcherConfig = getAppSwitcherConfig(), +): string | undefined { + return config.apps.find((app) => app.id === appId)?.url +} + export function getCurrentApp( config: AppSwitcherConfig = getAppSwitcherConfig(), ): AppSwitcherEntry { diff --git a/apps/web/src/queries/app-switcher.ts b/apps/web/src/queries/app-switcher.ts new file mode 100644 index 0000000..b62f680 --- /dev/null +++ b/apps/web/src/queries/app-switcher.ts @@ -0,0 +1,15 @@ +import { queryOptions } from '@tanstack/react-query' +import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher' +import { api } from '@/lib/api-client' +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' + +export const appSwitcherQueryKey = ['app-switcher'] as const + +export function appSwitcherQueryOptions() { + return queryOptions({ + queryKey: appSwitcherQueryKey, + queryFn: () => api.get('/settings/app-switcher'), + staleTime: 60_000, + placeholderData: DEFAULT_APP_SWITCHER_CONFIG, + }) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d6db8d9..c944627 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,7 +14,6 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as AuthVpsRouteImport } from './routes/_auth/vps' import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs' import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal' -import { Route as AuthSettingsRouteImport } from './routes/_auth/settings' import { Route as AuthResourcesRouteImport } from './routes/_auth/resources' import { Route as AuthReportsRouteImport } from './routes/_auth/reports' import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals' @@ -25,7 +24,10 @@ import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard' import { Route as AuthBalanceRouteImport } from './routes/_auth/balance' import { Route as AuthAuditRouteImport } from './routes/_auth/audit' import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts' +import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route' +import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index' import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId' +import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations' import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId' const AuthRoute = AuthRouteImport.update({ @@ -52,11 +54,6 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({ path: '/sync-journal', getParentRoute: () => AuthRoute, } as any) -const AuthSettingsRoute = AuthSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => AuthRoute, -} as any) const AuthResourcesRoute = AuthResourcesRouteImport.update({ id: '/resources', path: '/resources', @@ -107,11 +104,27 @@ const AuthAccountsRoute = AuthAccountsRouteImport.update({ path: '/accounts', getParentRoute: () => AuthRoute, } as any) +const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthRoute, +} as any) +const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthSettingsRouteRoute, +} as any) const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({ id: '/$vpsId', path: '/$vpsId', getParentRoute: () => AuthVpsRoute, } as any) +const AuthSettingsIntegrationsRoute = + AuthSettingsIntegrationsRouteImport.update({ + id: '/integrations', + path: '/integrations', + getParentRoute: () => AuthSettingsRouteRoute, + } as any) const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({ id: '/$projectId', path: '/$projectId', @@ -120,6 +133,7 @@ const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/settings': typeof AuthSettingsRouteRouteWithChildren '/accounts': typeof AuthAccountsRoute '/audit': typeof AuthAuditRoute '/balance': typeof AuthBalanceRoute @@ -130,12 +144,13 @@ export interface FileRoutesByFullPath { '/renewals': typeof AuthRenewalsRoute '/reports': typeof AuthReportsRoute '/resources': typeof AuthResourcesRoute - '/settings': typeof AuthSettingsRoute '/sync-journal': typeof AuthSyncJournalRoute '/tariffs': typeof AuthTariffsRoute '/vps': typeof AuthVpsRouteWithChildren '/projects/$projectId': typeof AuthProjectsProjectIdRoute + '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/vps/$vpsId': typeof AuthVpsVpsIdRoute + '/settings/': typeof AuthSettingsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -149,17 +164,19 @@ export interface FileRoutesByTo { '/renewals': typeof AuthRenewalsRoute '/reports': typeof AuthReportsRoute '/resources': typeof AuthResourcesRoute - '/settings': typeof AuthSettingsRoute '/sync-journal': typeof AuthSyncJournalRoute '/tariffs': typeof AuthTariffsRoute '/vps': typeof AuthVpsRouteWithChildren '/projects/$projectId': typeof AuthProjectsProjectIdRoute + '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/vps/$vpsId': typeof AuthVpsVpsIdRoute + '/settings': typeof AuthSettingsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_auth': typeof AuthRouteWithChildren + '/_auth/settings': typeof AuthSettingsRouteRouteWithChildren '/_auth/accounts': typeof AuthAccountsRoute '/_auth/audit': typeof AuthAuditRoute '/_auth/balance': typeof AuthBalanceRoute @@ -170,17 +187,19 @@ export interface FileRoutesById { '/_auth/renewals': typeof AuthRenewalsRoute '/_auth/reports': typeof AuthReportsRoute '/_auth/resources': typeof AuthResourcesRoute - '/_auth/settings': typeof AuthSettingsRoute '/_auth/sync-journal': typeof AuthSyncJournalRoute '/_auth/tariffs': typeof AuthTariffsRoute '/_auth/vps': typeof AuthVpsRouteWithChildren '/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute + '/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute '/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute + '/_auth/settings/': typeof AuthSettingsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/settings' | '/accounts' | '/audit' | '/balance' @@ -191,12 +210,13 @@ export interface FileRouteTypes { | '/renewals' | '/reports' | '/resources' - | '/settings' | '/sync-journal' | '/tariffs' | '/vps' | '/projects/$projectId' + | '/settings/integrations' | '/vps/$vpsId' + | '/settings/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -210,16 +230,18 @@ export interface FileRouteTypes { | '/renewals' | '/reports' | '/resources' - | '/settings' | '/sync-journal' | '/tariffs' | '/vps' | '/projects/$projectId' + | '/settings/integrations' | '/vps/$vpsId' + | '/settings' id: | '__root__' | '/' | '/_auth' + | '/_auth/settings' | '/_auth/accounts' | '/_auth/audit' | '/_auth/balance' @@ -230,12 +252,13 @@ export interface FileRouteTypes { | '/_auth/renewals' | '/_auth/reports' | '/_auth/resources' - | '/_auth/settings' | '/_auth/sync-journal' | '/_auth/tariffs' | '/_auth/vps' | '/_auth/projects/$projectId' + | '/_auth/settings/integrations' | '/_auth/vps/$vpsId' + | '/_auth/settings/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -280,13 +303,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSyncJournalRouteImport parentRoute: typeof AuthRoute } - '/_auth/settings': { - id: '/_auth/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof AuthSettingsRouteImport - parentRoute: typeof AuthRoute - } '/_auth/resources': { id: '/_auth/resources' path: '/resources' @@ -357,6 +373,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAccountsRouteImport parentRoute: typeof AuthRoute } + '/_auth/settings': { + id: '/_auth/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthSettingsRouteRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/settings/': { + id: '/_auth/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof AuthSettingsIndexRouteImport + parentRoute: typeof AuthSettingsRouteRoute + } '/_auth/vps/$vpsId': { id: '/_auth/vps/$vpsId' path: '/$vpsId' @@ -364,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthVpsVpsIdRouteImport parentRoute: typeof AuthVpsRoute } + '/_auth/settings/integrations': { + id: '/_auth/settings/integrations' + path: '/integrations' + fullPath: '/settings/integrations' + preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport + parentRoute: typeof AuthSettingsRouteRoute + } '/_auth/projects/$projectId': { id: '/_auth/projects/$projectId' path: '/$projectId' @@ -374,6 +411,19 @@ declare module '@tanstack/react-router' { } } +interface AuthSettingsRouteRouteChildren { + AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute + AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute +} + +const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = { + AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute, + AuthSettingsIndexRoute: AuthSettingsIndexRoute, +} + +const AuthSettingsRouteRouteWithChildren = + AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren) + interface AuthProjectsRouteChildren { AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute } @@ -398,6 +448,7 @@ const AuthVpsRouteWithChildren = AuthVpsRoute._addFileChildren(AuthVpsRouteChildren) interface AuthRouteChildren { + AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren AuthAccountsRoute: typeof AuthAccountsRoute AuthAuditRoute: typeof AuthAuditRoute AuthBalanceRoute: typeof AuthBalanceRoute @@ -408,13 +459,13 @@ interface AuthRouteChildren { AuthRenewalsRoute: typeof AuthRenewalsRoute AuthReportsRoute: typeof AuthReportsRoute AuthResourcesRoute: typeof AuthResourcesRoute - AuthSettingsRoute: typeof AuthSettingsRoute AuthSyncJournalRoute: typeof AuthSyncJournalRoute AuthTariffsRoute: typeof AuthTariffsRoute AuthVpsRoute: typeof AuthVpsRouteWithChildren } const AuthRouteChildren: AuthRouteChildren = { + AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren, AuthAccountsRoute: AuthAccountsRoute, AuthAuditRoute: AuthAuditRoute, AuthBalanceRoute: AuthBalanceRoute, @@ -425,7 +476,6 @@ const AuthRouteChildren: AuthRouteChildren = { AuthRenewalsRoute: AuthRenewalsRoute, AuthReportsRoute: AuthReportsRoute, AuthResourcesRoute: AuthResourcesRoute, - AuthSettingsRoute: AuthSettingsRoute, AuthSyncJournalRoute: AuthSyncJournalRoute, AuthTariffsRoute: AuthTariffsRoute, AuthVpsRoute: AuthVpsRouteWithChildren, diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings/index.tsx similarity index 98% rename from apps/web/src/routes/_auth/settings.tsx rename to apps/web/src/routes/_auth/settings/index.tsx index c55482b..7cbabfc 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings/index.tsx @@ -8,8 +8,6 @@ import { useMemo, useCallback } from 'react' import { snapshotQueryOptions } from '@/queries/snapshot' import { api, ApiError } from '@/lib/api-client' -import { PageShell } from '@/components/page-shell' -import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { SectionCardsSkeleton } from '@/components/skeletons' import { EmptyState } from '@/components/empty-state' @@ -35,7 +33,7 @@ import { settingsSchema, type SettingsFormValues } from '@/lib/schemas' import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor' import type { NotificationLogRow, Settings } from '@/types/entities' -export const Route = createFileRoute('/_auth/settings')({ +export const Route = createFileRoute('/_auth/settings/')({ loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(snapshotQueryOptions()), component: SettingsPage, @@ -316,12 +314,8 @@ function SettingsPage() { ) return ( - - + <> +
{backupActions}
)} -
+ ) } diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx new file mode 100644 index 0000000..3e2cfba --- /dev/null +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -0,0 +1,60 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { snapshotQueryOptions } from '@/queries/snapshot' +import { appSwitcherQueryKey } from '@/queries/app-switcher' +import { api } from '@/lib/api-client' +import { QueryState } from '@/components/query-state' +import { SectionCardsSkeleton } from '@/components/skeletons' +import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor' +import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card' +import type { Settings } from '@/types/entities' +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' + +export const Route = createFileRoute('/_auth/settings/integrations')({ + component: SettingsIntegrationsPage, +}) + +function SettingsIntegrationsPage() { + const queryClient = useQueryClient() + const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) + const current = snapshot?.settings?.[0] as Settings | undefined + + const saveMut = useMutation({ + mutationFn: (patch: Partial & { appSwitcher?: Settings['appSwitcher'] }) => + api.update('settings', current?.id ?? 'settings-main', patch), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) + await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey }) + toast.success('Настройки интеграции сохранены') + }, + onError: () => toast.error('Не удалось сохранить'), + }) + + return ( + refetch()} + skeleton={} + > + {() => ( +
+ saveMut.mutate({ appSwitcher })} + /> + saveMut.mutate(patch)} + /> +
+ )} +
+ ) +} diff --git a/apps/web/src/routes/_auth/settings/route.tsx b/apps/web/src/routes/_auth/settings/route.tsx new file mode 100644 index 0000000..74f46e2 --- /dev/null +++ b/apps/web/src/routes/_auth/settings/route.tsx @@ -0,0 +1,49 @@ +import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router' +import { cn } from '@cfdm/ui/lib/utils' + +import { snapshotQueryOptions } from '@/queries/snapshot' +import { PageShell } from '@/components/page-shell' +import { PageHeader } from '@/components/page-header' + +export const Route = createFileRoute('/_auth/settings')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(snapshotQueryOptions()), + component: SettingsLayout, +}) + +const TABS = [ + { to: '/settings', label: 'Общие', exact: true }, + { to: '/settings/integrations', label: 'Интеграции', exact: false }, +] as const + +function SettingsLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + + return ( + + + + + + ) +} diff --git a/apps/web/src/routes/_auth/vps.$vpsId.tsx b/apps/web/src/routes/_auth/vps.$vpsId.tsx index 919025f..72d7a6f 100644 --- a/apps/web/src/routes/_auth/vps.$vpsId.tsx +++ b/apps/web/src/routes/_auth/vps.$vpsId.tsx @@ -41,6 +41,7 @@ import { formatCustomFieldValue, } from '@/lib/custom-fields' import type { Payment, Vps } from '@/types/entities' +import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell' export const Route = createFileRoute('/_auth/vps/$vpsId')({ loader: ({ context: { queryClient } }) => @@ -85,6 +86,11 @@ function VpsDetailPage() { [snapshot, vpsId], ) + const vpsDomains = useMemo( + () => (snapshot?.vpsDomains ?? []).filter((d) => d.vpsId === vpsId), + [snapshot, vpsId], + ) + const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : [] const customFieldDefs = useMemo( @@ -200,6 +206,16 @@ function VpsDetailPage() { + {vpsDomains.length > 0 ? ( + + + Домены (CFDM) + + + + + + ) : null} {customFieldRows.length > 0 ? ( diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index bb593f1..7dc4b09 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -31,6 +31,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar' import { HealthModeBanner } from '@/components/health-mode-banner' import { ProjectColorDot } from '@/components/project-color-dot' import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar' +import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell' import type { Vps } from '@/types/entities' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager' @@ -346,6 +347,19 @@ function VpsPage() { v.dns || undefined, ), }, + { + key: 'domains', + header: 'Домены', + icon: GlobeIcon, + sortValue: (v) => + (snapshot?.vpsDomains ?? []) + .filter((d) => d.vpsId === v.id) + .map((d) => d.fqdn) + .join(', '), + cell: (v) => ( + d.vpsId === v.id)} /> + ), + }, { key: 'account', header: 'Аккаунт', @@ -513,6 +527,10 @@ function VpsPage() { } /> + {snapshot?.vpsDomains?.length ? ( + + ) : null} + & { +const DEFAULT_APP_SWITCHER: AppSwitcherConfig = { + menuLabel: 'Приложения', + apps: [ + { + id: 'vps-tracker', + name: 'VPS Tracker', + subtitle: 'Учёт виртуальных серверов', + url: 'http://192.168.100.67:3001', + icon: 'server', + shortcut: '⌘1', + }, + { + id: 'cfdm', + name: 'CF Domain Manager', + subtitle: 'Управление доменами', + url: 'http://192.168.100.67:6363', + icon: 'cloud', + shortcut: '⌘2', + }, + ], +} + +export type SettingsDto = Omit< + Row, + | 'telegramBotToken' + | 'integrationToken' + | 'autoConvert' + | 'syncEnabled' + | 'notifyPaymentExpiryEnabled' + | 'notifyNewTariffsEnabled' + | 'notifyLowBalanceEnabled' + | 'notifySyncDigestEnabled' + | 'notifyVpsDownEnabled' + | 'webhookEnabled' + | 'integrationEnabled' + | 'customFields' + | 'appSwitcherJson' +> & { telegramBotTokenSet: boolean + integrationTokenSet: boolean autoConvert: boolean syncEnabled: boolean notifyPaymentExpiryEnabled: boolean @@ -13,9 +55,20 @@ export type SettingsDto = Omit @@ -19,6 +20,7 @@ export interface Snapshot { activeTariffs: ReturnType tariffSyncOptions: ReturnType syncLog: ReturnType + vpsDomains: ReturnType } export function getSnapshot(): Snapshot { @@ -33,6 +35,7 @@ export function getSnapshot(): Snapshot { activeTariffs: activeTariffsRepository.list(), tariffSyncOptions: tariffSyncOptionsRepository.list(), syncLog: syncLogRepository.listRecent(50), + vpsDomains: vpsDomainsRepository.list(), } } @@ -47,4 +50,5 @@ export { tariffSyncOptionsRepository, projectsRepository, syncLogRepository, + vpsDomainsRepository, } diff --git a/packages/db/src/repositories/vps-domains.test.ts b/packages/db/src/repositories/vps-domains.test.ts new file mode 100644 index 0000000..508ee32 --- /dev/null +++ b/packages/db/src/repositories/vps-domains.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb } from '../index.js' +import { vpsRepository } from './vps.js' +import { vpsDomainsRepository } from './vps-domains.js' +import { settingsRepository } from './settings.js' +import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '../test-setup.js' + +describe('vpsDomainsRepository', () => { + beforeEach(() => { + resetTestDb() + seedTestProvider('p1') + seedTestProviderAccount('a1', 'p1') + }) + + afterEach(() => { + closeDb() + }) + + it('привязывает домен к VPS по IP', () => { + const vps = vpsRepository.create({ + ip: '203.0.113.10', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }) + const created = Array.isArray(vps) ? vps[0]! : vps + + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 1, + serviceId: 10, + serviceName: 'VPN Node', + serviceSlug: 'vpn-node', + fqdn: 'vpn.example.com', + zoneName: 'example.com', + hostname: 'vpn', + ips: ['203.0.113.10'], + }, + ]) + + expect(result.upserted).toBe(1) + expect(result.matched).toBe(1) + const domains = vpsDomainsRepository.listByVpsId(created.id) + expect(domains).toHaveLength(1) + expect(domains[0]?.fqdn).toBe('vpn.example.com') + expect(domains[0]?.matchStatus).toBe('matched') + }) + + it('помечает unmatched без совпадения IP', () => { + const result = vpsDomainsRepository.syncBindings([ + { + bindingId: 2, + serviceId: 11, + serviceName: 'CDN', + serviceSlug: 'cdn', + fqdn: 'cdn.example.com', + zoneName: 'example.com', + hostname: 'cdn', + ips: ['198.51.100.1'], + }, + ]) + expect(result.unmatched).toBe(1) + expect(vpsDomainsRepository.listUnmatched()).toHaveLength(1) + }) +}) + +describe('settingsRepository integration fields', () => { + beforeEach(() => { + resetTestDb() + }) + + afterEach(() => { + closeDb() + }) + + it('маскирует integration token в DTO', () => { + settingsRepository.upsert('settings-main', { + integrationToken: 'secret-token-value', + integrationEnabled: true, + }) + const dto = settingsRepository.get('settings-main') + expect(dto?.integrationTokenSet).toBe(true) + expect(dto).not.toHaveProperty('integrationToken') + }) +}) diff --git a/packages/db/src/repositories/vps-domains.ts b/packages/db/src/repositories/vps-domains.ts new file mode 100644 index 0000000..a97e36c --- /dev/null +++ b/packages/db/src/repositories/vps-domains.ts @@ -0,0 +1,196 @@ +import { asc, eq, isNull } from 'drizzle-orm' +import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm' +import { getDb, schema } from '../index.js' +import { generateId } from './utils.js' +import { vpsRepository } from './vps.js' + +type Row = typeof schema.vpsDomains.$inferSelect + +export type VpsDomainDto = Row + +function normalizeIp(ip: string): string { + return ip.trim().toLowerCase() +} + +function collectVpsIps(vps: { ip?: string | null; additionalIps?: string[] }): string[] { + const ips: string[] = [] + if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip)) + for (const raw of vps.additionalIps ?? []) { + if (raw?.trim()) ips.push(normalizeIp(raw)) + } + return ips +} + +function findVpsIdByIps( + allVps: ReturnType, + ips: string[], +): string | null { + const normalized = [...new Set(ips.map(normalizeIp).filter(Boolean))] + if (normalized.length === 0) return null + + const matches: string[] = [] + for (const v of allVps) { + const vips = collectVpsIps(v) + if (normalized.some((ip) => vips.includes(ip))) { + matches.push(v.id) + } + } + if (matches.length === 1) return matches[0]! + return null +} + +function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' { + return vpsId ? 'matched' : 'unmatched' +} + +export const vpsDomainsRepository = { + list(): VpsDomainDto[] { + return getDb() + .select() + .from(schema.vpsDomains) + .orderBy(asc(schema.vpsDomains.fqdn)) + .all() + }, + + listByVpsId(vpsId: string): VpsDomainDto[] { + return getDb() + .select() + .from(schema.vpsDomains) + .where(eq(schema.vpsDomains.vpsId, vpsId)) + .orderBy(asc(schema.vpsDomains.fqdn)) + .all() + }, + + getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined { + return getDb() + .select() + .from(schema.vpsDomains) + .where(eq(schema.vpsDomains.cfdmBindingId, bindingId)) + .get() + }, + + deleteByCfdmBindingId(bindingId: number): boolean { + const row = this.getByCfdmBindingId(bindingId) + if (!row) return false + getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run() + return true + }, + + rematchAll(): { updated: number } { + const db = getDb() + const allVps = vpsRepository.list() + const rows = db.select().from(schema.vpsDomains).all() + let updated = 0 + const vpsIds = new Set(allVps.map((v) => v.id)) + + for (const row of rows) { + let storedIps: string[] = [] + try { + storedIps = row.targetIps ? JSON.parse(row.targetIps) : [] + } catch { + storedIps = [] + } + + let vpsId = row.vpsId + if (vpsId && !vpsIds.has(vpsId)) { + vpsId = null + } + if (!vpsId && storedIps.length > 0) { + vpsId = findVpsIdByIps(allVps, storedIps) + } + const matchStatus = + vpsId && vpsIds.has(vpsId) + ? 'matched' + : row.vpsId && !vpsIds.has(row.vpsId) + ? 'orphaned' + : resolveMatchStatus(vpsId) + + if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) { + db.update(schema.vpsDomains) + .set({ vpsId, matchStatus }) + .where(eq(schema.vpsDomains.id, row.id)) + .run() + updated++ + } + } + return { updated } + }, + + syncBindings(items: CfdmBindingSyncItem[]): { + matched: number + unmatched: number + deleted: number + upserted: number + } { + const db = getDb() + const allVps = vpsRepository.list() + const now = new Date().toISOString() + let matched = 0 + let unmatched = 0 + let deleted = 0 + let upserted = 0 + + for (const item of items) { + if (item.deleted) { + if (this.deleteByCfdmBindingId(item.bindingId)) deleted++ + continue + } + + const vpsId = findVpsIdByIps(allVps, item.ips) + const matchStatus = resolveMatchStatus(vpsId) + if (matchStatus === 'matched') matched++ + else unmatched++ + + const existing = this.getByCfdmBindingId(item.bindingId) + const values = { + vpsId, + fqdn: item.fqdn, + zoneName: item.zoneName, + hostname: item.hostname, + serviceName: item.serviceName, + serviceSlug: item.serviceSlug, + cfdmServiceId: item.serviceId, + cfdmBindingId: item.bindingId, + source: 'cfdm' as const, + matchStatus, + targetIps: JSON.stringify(item.ips), + syncedAt: now, + } + + if (existing) { + db.update(schema.vpsDomains).set(values).where(eq(schema.vpsDomains.id, existing.id)).run() + } else { + db.insert(schema.vpsDomains).values({ id: generateId('vd'), ...values }).run() + } + upserted++ + } + + return { matched, unmatched, deleted, upserted } + }, + + markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number { + const db = getDb() + const rows = db + .select() + .from(schema.vpsDomains) + .where(eq(schema.vpsDomains.cfdmServiceId, serviceId)) + .all() + let removed = 0 + for (const row of rows) { + if (!keptBindingIds.includes(row.cfdmBindingId)) { + db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run() + removed++ + } + } + return removed + }, + + listUnmatched(): VpsDomainDto[] { + return getDb() + .select() + .from(schema.vpsDomains) + .where(isNull(schema.vpsDomains.vpsId)) + .orderBy(asc(schema.vpsDomains.fqdn)) + .all() + }, +} diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 2337012..0972f9d 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -9,6 +9,12 @@ const COLUMN_MIGRATIONS: string[] = [ `ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`, `ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`, `ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes INTEGER`, + `ALTER TABLE settings ADD COLUMN appSwitcherJson TEXT`, + `ALTER TABLE settings ADD COLUMN integrationToken TEXT`, + `ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`, + `ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`, + `ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`, + `ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`, ] const TABLE_MIGRATIONS: string[] = [ @@ -52,6 +58,21 @@ const TABLE_MIGRATIONS: string[] = [ notes TEXT, createdAt TEXT )`, + `CREATE TABLE IF NOT EXISTS vps_domains ( + id TEXT PRIMARY KEY, + vpsId TEXT REFERENCES vps(id) ON DELETE SET NULL, + fqdn TEXT NOT NULL, + zoneName TEXT NOT NULL, + hostname TEXT NOT NULL, + serviceName TEXT NOT NULL, + serviceSlug TEXT NOT NULL, + cfdmServiceId INTEGER NOT NULL, + cfdmBindingId INTEGER NOT NULL UNIQUE, + source TEXT NOT NULL DEFAULT 'cfdm', + matchStatus TEXT NOT NULL DEFAULT 'unmatched', + targetIps TEXT, + syncedAt TEXT NOT NULL + )`, ] let migrated = false diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 93971eb..7e37f01 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -128,6 +128,27 @@ export const settings = sqliteTable('settings', { webhookEnabled: integer('webhookEnabled'), notifyIntervalMinutes: integer('notifyIntervalMinutes'), uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'), + appSwitcherJson: text('appSwitcherJson'), + integrationToken: text('integrationToken'), + integrationEnabled: integer('integrationEnabled'), + integrationLastSyncAt: text('integrationLastSyncAt'), + cfdmApiUrl: text('cfdmApiUrl'), +}) + +export const vpsDomains = sqliteTable('vps_domains', { + id: text('id').primaryKey(), + vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }), + fqdn: text('fqdn').notNull(), + zoneName: text('zoneName').notNull(), + hostname: text('hostname').notNull(), + serviceName: text('serviceName').notNull(), + serviceSlug: text('serviceSlug').notNull(), + cfdmServiceId: integer('cfdmServiceId').notNull(), + cfdmBindingId: integer('cfdmBindingId').notNull(), + source: text('source').notNull().default('cfdm'), + matchStatus: text('matchStatus').notNull().default('unmatched'), + targetIps: text('targetIps'), + syncedAt: text('syncedAt').notNull(), }) export const notificationLog = sqliteTable('notification_log', { diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index efc9164..0ce2a14 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -160,7 +160,29 @@ CREATE TABLE IF NOT EXISTS settings ( webhookUrl TEXT, webhookEnabled INTEGER, notifyIntervalMinutes INTEGER, - uptimeCheckIntervalMinutes INTEGER + uptimeCheckIntervalMinutes INTEGER, + appSwitcherJson TEXT, + integrationToken TEXT, + integrationEnabled INTEGER, + integrationLastSyncAt TEXT, + cfdmApiUrl TEXT +); + +CREATE TABLE IF NOT EXISTS vps_domains ( + id TEXT PRIMARY KEY, + vpsId TEXT, + fqdn TEXT NOT NULL, + zoneName TEXT NOT NULL, + hostname TEXT NOT NULL, + serviceName TEXT NOT NULL, + serviceSlug TEXT NOT NULL, + cfdmServiceId INTEGER NOT NULL, + cfdmBindingId INTEGER NOT NULL UNIQUE, + source TEXT NOT NULL DEFAULT 'cfdm', + matchStatus TEXT NOT NULL DEFAULT 'unmatched', + targetIps TEXT, + syncedAt TEXT NOT NULL, + FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS notification_log ( diff --git a/packages/shared/src/contracts/app-switcher.ts b/packages/shared/src/contracts/app-switcher.ts new file mode 100644 index 0000000..a7c0c6d --- /dev/null +++ b/packages/shared/src/contracts/app-switcher.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart']) + +export const appSwitcherEntrySchema = z.object({ + id: z.string(), + name: z.string(), + subtitle: z.string().optional(), + url: z.string().url('Невалидный URL'), + icon: appSwitcherIconSchema.default('server'), + shortcut: z.string().optional(), +}) + +export const appSwitcherConfigSchema = z.object({ + menuLabel: z.string().default('Приложения'), + apps: z.array(appSwitcherEntrySchema).min(1), +}) + +export type AppSwitcherEntry = z.infer +export type AppSwitcherConfig = z.infer diff --git a/packages/shared/src/contracts/integration-cfdm.ts b/packages/shared/src/contracts/integration-cfdm.ts new file mode 100644 index 0000000..92b65bd --- /dev/null +++ b/packages/shared/src/contracts/integration-cfdm.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +export const cfdmBindingSyncItemSchema = z.object({ + bindingId: z.number().int().positive(), + serviceId: z.number().int().positive(), + serviceName: z.string().min(1), + serviceSlug: z.string().min(1), + fqdn: z.string().min(1), + zoneName: z.string().min(1), + hostname: z.string(), + ips: z.array(z.string()), + deleted: z.boolean().optional(), +}) + +export const cfdmSyncBindingsBodySchema = z.object({ + bindings: z.array(cfdmBindingSyncItemSchema).min(1), +}) + +export type CfdmBindingSyncItem = z.infer +export type CfdmSyncBindingsBody = z.infer + +export const vpsTrackerEventSchema = z.object({ + event: z.enum(['vps_down', 'vps_up']), + vps: z.array( + z.object({ + id: z.string().min(1), + ip: z.string().optional(), + label: z.string().optional(), + }), + ), + timestamp: z.string().datetime().optional(), +}) + +export type VpsTrackerEvent = z.infer diff --git a/packages/shared/src/contracts/settings.ts b/packages/shared/src/contracts/settings.ts index 529e60e..6824652 100644 --- a/packages/shared/src/contracts/settings.ts +++ b/packages/shared/src/contracts/settings.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { customFieldsSchema } from './custom-fields.js' +import { appSwitcherConfigSchema } from './app-switcher.js' export const settingsSchema = z.object({ id: z.string().optional(), @@ -23,6 +24,9 @@ export const settingsSchema = z.object({ webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(), webhookEnabled: z.boolean().optional(), customFields: customFieldsSchema.optional(), + appSwitcher: appSwitcherConfigSchema.optional(), + integrationToken: z.string().optional(), + integrationEnabled: z.boolean().optional(), }) export type Settings = z.infer