Синхронизация 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:
@@ -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)) {
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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: Настройки → Интеграции
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+4
-10
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user