feat: интеграция с CF Domain Manager
Docker / build (push) Failing after 22s

Синхронизация bindings в vps_domains с привязкой по IP, API приёма с Bearer-токеном, настройки и App Switcher из SQLite, UI доменов на VPS и уведомление CFDM при vps_down.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-30 16:45:16 +07:00
co-authored by Cursor
parent 2b01063279
commit 0e9a6a6a21
34 changed files with 1326 additions and 39 deletions
+4
View File
@@ -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)) {
+43
View File
@@ -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<void> {
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' },
})
}
}
+8
View File
@@ -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()
})
}
@@ -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<ReturnType<typeof buildApp>>
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' })
})
})
+34
View File
@@ -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,
}
},
)
}
+6
View File
@@ -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<string, unknown>)
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)
})
}
+57
View File
@@ -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<void> {
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)
}
}
+7
View File
@@ -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<void> {
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)
}
+1 -2
View File
@@ -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: Настройки → Интеграции
+3 -3
View File
@@ -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}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{config.menuLabel}
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
@@ -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<typeof formSchema>
interface AppSwitcherEditorProps {
defaultValues: AppSwitcherFormValues
onSave: (values: AppSwitcherFormValues) => void
isSaving?: boolean
}
export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) {
const form = useForm<AppSwitcherFormValues>({
resolver: zodResolver(formSchema),
defaultValues,
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<Card>
<CardHeader>
<CardTitle>Связанные приложения</CardTitle>
<CardDescription>URL для переключателя в sidebar и deep links</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit(onSave)(e)}
>
<FieldGroup>
<FormField label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormField>
{fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
<FormField label="ID" htmlFor={`app-id-${index}`}>
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
</FormField>
<FormField label="Название" htmlFor={`app-name-${index}`}>
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
</FormField>
<FormField label="URL" htmlFor={`app-url-${index}`}>
<Input id={`app-url-${index}`} className="md:col-span-2" {...form.register(`apps.${index}.url`)} />
</FormField>
<FormField label="Иконка" htmlFor={`app-icon-${index}`}>
<SelectField
triggerId={`app-icon-${index}`}
value={form.watch(`apps.${index}.icon`)}
onValueChange={(v) =>
form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, {
shouldDirty: true,
})
}
options={ICON_OPTIONS}
/>
</FormField>
<div className="flex items-end justify-end">
<Button
type="button"
variant="outline"
size="icon"
disabled={fields.length <= 1}
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
Сохранить приложения
</LoadingButton>
</form>
</CardContent>
</Card>
)
}
@@ -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<typeof formSchema>
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<FormValues>({
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 (
<Card>
<CardHeader>
<CardTitle>CF Domain Manager</CardTitle>
<CardDescription>
Приём синхронизации доменов и сервисов из CFDM. Скопируйте токен в настройки CFDM.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}>
<FieldGroup>
<FormField label="URL API CFDM" htmlFor="cfdm-api-url">
<Input
id="cfdm-api-url"
placeholder="http://192.168.100.67:6363 (для failover vps_down)"
{...form.register('cfdmApiUrl')}
/>
</FormField>
<FormField label="Integration token" htmlFor="integration-token">
<div className="flex gap-2">
<Input
id="integration-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.integrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Сгенерируйте или вставьте токен'
}
{...form.register('integrationToken')}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const token = generateToken()
form.setValue('integrationToken', token, { shouldDirty: true })
void navigator.clipboard.writeText(token)
toast.success('Токен сгенерирован и скопирован')
}}
>
Сгенерировать
</Button>
</div>
</FormField>
<Controller
control={form.control}
name="integrationEnabled"
render={({ field }) => (
<FormField label="Принимать синхронизацию" htmlFor="integration-enabled">
<SelectField
triggerId="integration-enabled"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormField>
)}
/>
{settings?.integrationLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync: {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
Сохранить интеграцию
</LoadingButton>
</form>
</CardContent>
</Card>
)
}
@@ -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 <span className="text-muted-foreground"></span>
return (
<div className="flex max-w-xs flex-col gap-1">
{domains.slice(0, 3).map((d) => (
<div key={d.id} className="flex flex-wrap items-center gap-1">
<span className="truncate text-sm">{d.fqdn}</span>
{d.matchStatus !== 'matched' ? (
<Badge variant="warning" className="text-xs">
{d.matchStatus === 'orphaned' ? 'orphan' : 'unmatched'}
</Badge>
) : null}
{cfdmUrl ? (
<a
href={`${cfdmUrl.replace(/\/$/, '')}/services`}
className="text-muted-foreground hover:text-foreground"
title={`Сервис ${d.serviceName} в CFDM`}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3.5" />
</a>
) : null}
</div>
))}
{domains.length > 3 ? (
<span className="text-xs text-muted-foreground">+{domains.length - 3}</span>
) : null}
</div>
)
}
export function UnmatchedDomainsBanner({ domains }: { domains: VpsDomain[] }) {
const unmatched = domains.filter((d) => d.matchStatus === 'unmatched' || !d.vpsId)
if (unmatched.length === 0) return null
return (
<div className="rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<p className="font-medium">Домены без привязки к VPS: {unmatched.length}</p>
<ul className="mt-1 list-inside list-disc text-muted-foreground">
{unmatched.slice(0, 5).map((d) => (
<li key={d.id}>
{d.fqdn} ({d.serviceName})
</li>
))}
</ul>
<Link to="/settings/integrations" className="mt-2 inline-block text-sm underline">
Настройки интеграции
</Link>
</div>
)
}
+22
View File
@@ -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)
}
+1
View File
@@ -73,6 +73,7 @@ function uid(): string {
export const api = {
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
get: <T>(path: string) => fetchApi<T>(`/api/${path.replace(/^\//, '')}`),
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
+7
View File
@@ -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 {
+15
View File
@@ -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<AppSwitcherConfig>('/settings/app-switcher'),
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
})
}
+71 -21
View File
@@ -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,
@@ -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 (
<PageShell>
<PageHeader
title="Настройки"
description="Базовая валюта, курсы, синк, Telegram"
actions={backupActions}
/>
<>
<div className="flex flex-wrap gap-2">{backupActions}</div>
<QueryState
data={snapshot}
isLoading={isLoading}
@@ -604,6 +598,6 @@ function SettingsPage() {
</form>
)}
</QueryState>
</PageShell>
</>
)
}
@@ -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<Settings> & { appSwitcher?: Settings['appSwitcher'] }) =>
api.update<Settings>('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 (
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={2} />}
>
{() => (
<div className="flex flex-col gap-4">
<AppSwitcherEditor
defaultValues={current?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<CfdmIntegrationCard
settings={current}
isSaving={saveMut.isPending}
onSave={(patch) => saveMut.mutate(patch)}
/>
</div>
)}
</QueryState>
)
}
@@ -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 (
<PageShell>
<PageHeader title="Настройки" description="Параметры приложения и связи с другими сервисами" />
<nav className="flex gap-1 border-b pb-0">
{TABS.map((tab) => {
const active = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.to}
to={tab.to}
className={cn(
'rounded-t-md px-4 py-2 text-sm font-medium transition-colors',
active
? 'border border-b-0 bg-background text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<Outlet />
</PageShell>
)
}
+16
View File
@@ -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() {
<InfoRow label="Хостер" value={provider?.name || '—'} />
</CardContent>
</Card>
{vpsDomains.length > 0 ? (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="text-base">Домены (CFDM)</CardTitle>
</CardHeader>
<CardContent>
<VpsDomainsCell domains={vpsDomains} />
</CardContent>
</Card>
) : null}
</div>
{customFieldRows.length > 0 ? (
<Card>
+18
View File
@@ -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) => (
<VpsDomainsCell domains={(snapshot?.vpsDomains ?? []).filter((d) => d.vpsId === v.id)} />
),
},
{
key: 'account',
header: 'Аккаунт',
@@ -513,6 +527,10 @@ function VpsPage() {
}
/>
{snapshot?.vpsDomains?.length ? (
<UnmatchedDomainsBanner domains={snapshot.vpsDomains} />
) : null}
<QueryState
data={snapshot}
isLoading={isLoading}
+22
View File
@@ -129,6 +129,27 @@ export interface Settings {
telegramMessageThreadId?: string
telegramBotTokenSet?: boolean
customFields?: CustomFieldDef[]
appSwitcher?: import('@cfdm/shared/contracts/app-switcher').AppSwitcherConfig
integrationEnabled?: boolean
integrationTokenSet?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
}
export interface VpsDomain {
id: string
vpsId: string | null
fqdn: string
zoneName: string
hostname: string
serviceName: string
serviceSlug: string
cfdmServiceId: number
cfdmBindingId: number
source: string
matchStatus: 'matched' | 'unmatched' | 'orphaned'
targetIps?: string | null
syncedAt: string
}
export interface ActiveTariff {
@@ -210,4 +231,5 @@ export interface DataSnapshot {
tariffSyncOptions?: unknown[]
serverProjects?: ServerProject[]
syncLog: SyncLogRow[]
vpsDomains?: VpsDomain[]
}
+102 -2
View File
@@ -1,10 +1,52 @@
import { asc, eq } from 'drizzle-orm'
import {
appSwitcherConfigSchema,
type AppSwitcherConfig,
} from '@cfdm/shared/contracts/app-switcher'
import { getDb, schema } from '../index.js'
type Row = typeof schema.settings.$inferSelect
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'notifyVpsDownEnabled' | 'webhookEnabled' | 'customFields'> & {
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<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
notifySyncDigestEnabled: boolean
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
integrationEnabled: boolean
notifyIntervalMinutes: number
uptimeCheckIntervalMinutes: number
customFields: unknown[]
appSwitcher: AppSwitcherConfig
}
function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig {
if (!raw?.trim()) return DEFAULT_APP_SWITCHER
try {
return appSwitcherConfigSchema.parse(JSON.parse(raw))
} catch {
return DEFAULT_APP_SWITCHER
}
}
function toDto(row: Row | undefined): SettingsDto | undefined {
@@ -28,10 +81,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
return {
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
integrationTokenSet: Boolean(integrationToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
@@ -40,9 +94,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
integrationEnabled: Boolean(row.integrationEnabled),
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
customFields: Array.isArray(customFields) ? customFields : [],
appSwitcher: parseAppSwitcher(appSwitcherJson),
}
}
@@ -74,6 +130,11 @@ interface SettingsInput {
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
customFields?: unknown
appSwitcher?: AppSwitcherConfig
integrationToken?: string
integrationEnabled?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
@@ -156,6 +217,27 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
: existing?.uptimeCheckIntervalMinutes ?? 5,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
appSwitcherJson:
r.appSwitcher !== undefined
? JSON.stringify(r.appSwitcher)
: existing?.appSwitcherJson ?? JSON.stringify(DEFAULT_APP_SWITCHER),
integrationToken:
r.integrationToken !== undefined && String(r.integrationToken || '').trim() !== ''
? r.integrationToken
: existing?.integrationToken ?? '',
integrationEnabled:
r.integrationEnabled !== undefined
? r.integrationEnabled
? 1
: 0
: existing?.integrationEnabled
? 1
: 0,
integrationLastSyncAt:
r.integrationLastSyncAt !== undefined
? r.integrationLastSyncAt || ''
: existing?.integrationLastSyncAt ?? '',
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
}
}
@@ -170,6 +252,24 @@ export const settingsRepository = {
getRow(id: string): Row | undefined {
return getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get()
},
getIntegrationToken(id = 'settings-main'): string {
return this.getRow(id)?.integrationToken?.trim() ?? ''
},
getAppSwitcher(id = 'settings-main'): AppSwitcherConfig {
const row = this.getRow(id)
return parseAppSwitcher(row?.appSwitcherJson)
},
touchIntegrationSync(id = 'settings-main'): void {
const db = getDb()
const at = new Date().toISOString()
const existing = this.getRow(id)
if (existing) {
db.update(schema.settings)
.set({ integrationLastSyncAt: at })
.where(eq(schema.settings.id, id))
.run()
}
},
upsert(id: string, input: SettingsInput): SettingsDto {
const db = getDb()
const existing = this.getRow(id)
+4
View File
@@ -7,6 +7,7 @@ import { settingsRepository } from './settings.js'
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
import { vpsDomainsRepository } from './vps-domains.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
@@ -19,6 +20,7 @@ export interface Snapshot {
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
vpsDomains: ReturnType<typeof vpsDomainsRepository.list>
}
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,
}
@@ -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')
})
})
+196
View File
@@ -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<typeof vpsRepository.list>,
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()
},
}
+21
View File
@@ -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
+21
View File
@@ -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', {
+23 -1
View File
@@ -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 (
@@ -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<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
@@ -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<typeof cfdmBindingSyncItemSchema>
export type CfdmSyncBindingsBody = z.infer<typeof cfdmSyncBindingsBodySchema>
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<typeof vpsTrackerEventSchema>
@@ -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<typeof settingsSchema>