Синхронизация bindings в vps_domains с привязкой по IP, API приёма с Bearer-токеном, настройки и App Switcher из SQLite, UI доменов на VPS и уведомление CFDM при vps_down. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -23,6 +23,8 @@ import { migrateRoutes } from './routes/migrate.js'
|
|||||||
import { dashboardRoutes } from './routes/dashboard.js'
|
import { dashboardRoutes } from './routes/dashboard.js'
|
||||||
import { auditRoutes } from './routes/audit.js'
|
import { auditRoutes } from './routes/audit.js'
|
||||||
import { notificationsRoutes } from './routes/notifications.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'
|
import { startScheduler } from './services/scheduler.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -58,6 +60,8 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await app.register(dashboardRoutes)
|
await app.register(dashboardRoutes)
|
||||||
await app.register(auditRoutes)
|
await app.register(auditRoutes)
|
||||||
await app.register(notificationsRoutes)
|
await app.register(notificationsRoutes)
|
||||||
|
await app.register(integrationsCfdmRoutes)
|
||||||
|
await app.register(appSwitcherRoutes)
|
||||||
|
|
||||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||||
if (existsSync(staticDir)) {
|
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 type { FastifyPluginAsync } from 'fastify'
|
||||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||||
|
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
|
||||||
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
||||||
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
|
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
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>)
|
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
|
||||||
return updated
|
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' } })
|
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 { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
||||||
import { runAccountSync } from './providers/sync-job.js'
|
import { runAccountSync } from './providers/sync-job.js'
|
||||||
import { runVpsUptimeChecks } from './uptime-check.js'
|
import { runVpsUptimeChecks } from './uptime-check.js'
|
||||||
|
import { notifyCfdmVpsEvent } from './cfdm-notify.js'
|
||||||
import { publishMany, publishNotification } from './notifications/engine.js'
|
import { publishMany, publishNotification } from './notifications/engine.js'
|
||||||
import {
|
import {
|
||||||
buildLowBalanceNotification,
|
buildLowBalanceNotification,
|
||||||
@@ -154,6 +155,12 @@ export async function runScheduledUptimeChecks(): Promise<void> {
|
|||||||
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
if (newlyDown.length > 0) {
|
||||||
|
void notifyCfdmVpsEvent(
|
||||||
|
'vps_down',
|
||||||
|
newlyDown.map((h) => h.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# Frontend (Vite)
|
# Frontend (Vite)
|
||||||
# VITE_API_URL=
|
# VITE_API_URL=
|
||||||
|
|
||||||
# Список приложений для sidebar switcher (JSON, опционально)
|
# Публичные URL приложений и integration token — в UI: Настройки → Интеграции
|
||||||
# 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"}]}
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
APP_SWITCHER_ICONS,
|
APP_SWITCHER_ICONS,
|
||||||
CURRENT_APP_ID,
|
CURRENT_APP_ID,
|
||||||
getAppSwitcherConfig,
|
|
||||||
getCurrentApp,
|
getCurrentApp,
|
||||||
} from '@/lib/app-switcher-config'
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||||
|
|
||||||
export function AppSwitcher() {
|
export function AppSwitcher() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const config = getAppSwitcherConfig()
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
const current = getCurrentApp(config)
|
const current = getCurrentApp(config)
|
||||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export function AppSwitcher() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
{config.menuLabel}
|
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||||
</div>
|
</div>
|
||||||
{config.apps.map((app) => {
|
{config.apps.map((app) => {
|
||||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
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 = {
|
export const api = {
|
||||||
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
|
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
|
||||||
|
get: <T>(path: string) => fetchApi<T>(`/api/${path.replace(/^\//, '')}`),
|
||||||
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
|
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
|
||||||
|
|
||||||
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
|
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)
|
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(
|
export function getCurrentApp(
|
||||||
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||||
): AppSwitcherEntry {
|
): 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 AuthVpsRouteImport } from './routes/_auth/vps'
|
||||||
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
|
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
|
||||||
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
|
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 AuthResourcesRouteImport } from './routes/_auth/resources'
|
||||||
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
||||||
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
|
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 AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||||
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
||||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
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 AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
|
||||||
|
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
@@ -52,11 +54,6 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
|
|||||||
path: '/sync-journal',
|
path: '/sync-journal',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthSettingsRoute = AuthSettingsRouteImport.update({
|
|
||||||
id: '/settings',
|
|
||||||
path: '/settings',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthResourcesRoute = AuthResourcesRouteImport.update({
|
const AuthResourcesRoute = AuthResourcesRouteImport.update({
|
||||||
id: '/resources',
|
id: '/resources',
|
||||||
path: '/resources',
|
path: '/resources',
|
||||||
@@ -107,11 +104,27 @@ const AuthAccountsRoute = AuthAccountsRouteImport.update({
|
|||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} 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({
|
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
|
||||||
id: '/$vpsId',
|
id: '/$vpsId',
|
||||||
path: '/$vpsId',
|
path: '/$vpsId',
|
||||||
getParentRoute: () => AuthVpsRoute,
|
getParentRoute: () => AuthVpsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSettingsIntegrationsRoute =
|
||||||
|
AuthSettingsIntegrationsRouteImport.update({
|
||||||
|
id: '/integrations',
|
||||||
|
path: '/integrations',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
|
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
|
||||||
id: '/$projectId',
|
id: '/$projectId',
|
||||||
path: '/$projectId',
|
path: '/$projectId',
|
||||||
@@ -120,6 +133,7 @@ const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/accounts': typeof AuthAccountsRoute
|
'/accounts': typeof AuthAccountsRoute
|
||||||
'/audit': typeof AuthAuditRoute
|
'/audit': typeof AuthAuditRoute
|
||||||
'/balance': typeof AuthBalanceRoute
|
'/balance': typeof AuthBalanceRoute
|
||||||
@@ -130,12 +144,13 @@ export interface FileRoutesByFullPath {
|
|||||||
'/renewals': typeof AuthRenewalsRoute
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
'/resources': typeof AuthResourcesRoute
|
'/resources': typeof AuthResourcesRoute
|
||||||
'/settings': typeof AuthSettingsRoute
|
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
|
'/settings/': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -149,17 +164,19 @@ export interface FileRoutesByTo {
|
|||||||
'/renewals': typeof AuthRenewalsRoute
|
'/renewals': typeof AuthRenewalsRoute
|
||||||
'/reports': typeof AuthReportsRoute
|
'/reports': typeof AuthReportsRoute
|
||||||
'/resources': typeof AuthResourcesRoute
|
'/resources': typeof AuthResourcesRoute
|
||||||
'/settings': typeof AuthSettingsRoute
|
|
||||||
'/sync-journal': typeof AuthSyncJournalRoute
|
'/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/tariffs': typeof AuthTariffsRoute
|
'/tariffs': typeof AuthTariffsRoute
|
||||||
'/vps': typeof AuthVpsRouteWithChildren
|
'/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
|
'/settings': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
|
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/_auth/accounts': typeof AuthAccountsRoute
|
'/_auth/accounts': typeof AuthAccountsRoute
|
||||||
'/_auth/audit': typeof AuthAuditRoute
|
'/_auth/audit': typeof AuthAuditRoute
|
||||||
'/_auth/balance': typeof AuthBalanceRoute
|
'/_auth/balance': typeof AuthBalanceRoute
|
||||||
@@ -170,17 +187,19 @@ export interface FileRoutesById {
|
|||||||
'/_auth/renewals': typeof AuthRenewalsRoute
|
'/_auth/renewals': typeof AuthRenewalsRoute
|
||||||
'/_auth/reports': typeof AuthReportsRoute
|
'/_auth/reports': typeof AuthReportsRoute
|
||||||
'/_auth/resources': typeof AuthResourcesRoute
|
'/_auth/resources': typeof AuthResourcesRoute
|
||||||
'/_auth/settings': typeof AuthSettingsRoute
|
|
||||||
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
'/_auth/sync-journal': typeof AuthSyncJournalRoute
|
||||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||||
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
'/_auth/vps': typeof AuthVpsRouteWithChildren
|
||||||
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
|
||||||
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
|
||||||
|
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/settings'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/balance'
|
| '/balance'
|
||||||
@@ -191,12 +210,13 @@ export interface FileRouteTypes {
|
|||||||
| '/renewals'
|
| '/renewals'
|
||||||
| '/reports'
|
| '/reports'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
| '/settings'
|
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
|
| '/settings/integrations'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
|
| '/settings/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -210,16 +230,18 @@ export interface FileRouteTypes {
|
|||||||
| '/renewals'
|
| '/renewals'
|
||||||
| '/reports'
|
| '/reports'
|
||||||
| '/resources'
|
| '/resources'
|
||||||
| '/settings'
|
|
||||||
| '/sync-journal'
|
| '/sync-journal'
|
||||||
| '/tariffs'
|
| '/tariffs'
|
||||||
| '/vps'
|
| '/vps'
|
||||||
| '/projects/$projectId'
|
| '/projects/$projectId'
|
||||||
|
| '/settings/integrations'
|
||||||
| '/vps/$vpsId'
|
| '/vps/$vpsId'
|
||||||
|
| '/settings'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
|
| '/_auth/settings'
|
||||||
| '/_auth/accounts'
|
| '/_auth/accounts'
|
||||||
| '/_auth/audit'
|
| '/_auth/audit'
|
||||||
| '/_auth/balance'
|
| '/_auth/balance'
|
||||||
@@ -230,12 +252,13 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/renewals'
|
| '/_auth/renewals'
|
||||||
| '/_auth/reports'
|
| '/_auth/reports'
|
||||||
| '/_auth/resources'
|
| '/_auth/resources'
|
||||||
| '/_auth/settings'
|
|
||||||
| '/_auth/sync-journal'
|
| '/_auth/sync-journal'
|
||||||
| '/_auth/tariffs'
|
| '/_auth/tariffs'
|
||||||
| '/_auth/vps'
|
| '/_auth/vps'
|
||||||
| '/_auth/projects/$projectId'
|
| '/_auth/projects/$projectId'
|
||||||
|
| '/_auth/settings/integrations'
|
||||||
| '/_auth/vps/$vpsId'
|
| '/_auth/vps/$vpsId'
|
||||||
|
| '/_auth/settings/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -280,13 +303,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSyncJournalRouteImport
|
preLoaderRoute: typeof AuthSyncJournalRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/settings': {
|
|
||||||
id: '/_auth/settings'
|
|
||||||
path: '/settings'
|
|
||||||
fullPath: '/settings'
|
|
||||||
preLoaderRoute: typeof AuthSettingsRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/resources': {
|
'/_auth/resources': {
|
||||||
id: '/_auth/resources'
|
id: '/_auth/resources'
|
||||||
path: '/resources'
|
path: '/resources'
|
||||||
@@ -357,6 +373,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAccountsRouteImport
|
preLoaderRoute: typeof AuthAccountsRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
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': {
|
'/_auth/vps/$vpsId': {
|
||||||
id: '/_auth/vps/$vpsId'
|
id: '/_auth/vps/$vpsId'
|
||||||
path: '/$vpsId'
|
path: '/$vpsId'
|
||||||
@@ -364,6 +394,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
|
||||||
parentRoute: typeof AuthVpsRoute
|
parentRoute: typeof AuthVpsRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/settings/integrations': {
|
||||||
|
id: '/_auth/settings/integrations'
|
||||||
|
path: '/integrations'
|
||||||
|
fullPath: '/settings/integrations'
|
||||||
|
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||||
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
|
}
|
||||||
'/_auth/projects/$projectId': {
|
'/_auth/projects/$projectId': {
|
||||||
id: '/_auth/projects/$projectId'
|
id: '/_auth/projects/$projectId'
|
||||||
path: '/$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 {
|
interface AuthProjectsRouteChildren {
|
||||||
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
|
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
|
||||||
}
|
}
|
||||||
@@ -398,6 +448,7 @@ const AuthVpsRouteWithChildren =
|
|||||||
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
|
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
|
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
||||||
AuthAccountsRoute: typeof AuthAccountsRoute
|
AuthAccountsRoute: typeof AuthAccountsRoute
|
||||||
AuthAuditRoute: typeof AuthAuditRoute
|
AuthAuditRoute: typeof AuthAuditRoute
|
||||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||||
@@ -408,13 +459,13 @@ interface AuthRouteChildren {
|
|||||||
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
AuthRenewalsRoute: typeof AuthRenewalsRoute
|
||||||
AuthReportsRoute: typeof AuthReportsRoute
|
AuthReportsRoute: typeof AuthReportsRoute
|
||||||
AuthResourcesRoute: typeof AuthResourcesRoute
|
AuthResourcesRoute: typeof AuthResourcesRoute
|
||||||
AuthSettingsRoute: typeof AuthSettingsRoute
|
|
||||||
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
|
||||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||||
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
AuthVpsRoute: typeof AuthVpsRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
|
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
||||||
AuthAccountsRoute: AuthAccountsRoute,
|
AuthAccountsRoute: AuthAccountsRoute,
|
||||||
AuthAuditRoute: AuthAuditRoute,
|
AuthAuditRoute: AuthAuditRoute,
|
||||||
AuthBalanceRoute: AuthBalanceRoute,
|
AuthBalanceRoute: AuthBalanceRoute,
|
||||||
@@ -425,7 +476,6 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
AuthRenewalsRoute: AuthRenewalsRoute,
|
AuthRenewalsRoute: AuthRenewalsRoute,
|
||||||
AuthReportsRoute: AuthReportsRoute,
|
AuthReportsRoute: AuthReportsRoute,
|
||||||
AuthResourcesRoute: AuthResourcesRoute,
|
AuthResourcesRoute: AuthResourcesRoute,
|
||||||
AuthSettingsRoute: AuthSettingsRoute,
|
|
||||||
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
AuthSyncJournalRoute: AuthSyncJournalRoute,
|
||||||
AuthTariffsRoute: AuthTariffsRoute,
|
AuthTariffsRoute: AuthTariffsRoute,
|
||||||
AuthVpsRoute: AuthVpsRouteWithChildren,
|
AuthVpsRoute: AuthVpsRouteWithChildren,
|
||||||
|
|||||||
+4
-10
@@ -8,8 +8,6 @@ import { useMemo, useCallback } from 'react'
|
|||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
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 { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
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 { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
||||||
import type { NotificationLogRow, Settings } from '@/types/entities'
|
import type { NotificationLogRow, Settings } from '@/types/entities'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings/')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
@@ -316,12 +314,8 @@ function SettingsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<>
|
||||||
<PageHeader
|
<div className="flex flex-wrap gap-2">{backupActions}</div>
|
||||||
title="Настройки"
|
|
||||||
description="Базовая валюта, курсы, синк, Telegram"
|
|
||||||
actions={backupActions}
|
|
||||||
/>
|
|
||||||
<QueryState
|
<QueryState
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -604,6 +598,6 @@ function SettingsPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</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,
|
formatCustomFieldValue,
|
||||||
} from '@/lib/custom-fields'
|
} from '@/lib/custom-fields'
|
||||||
import type { Payment, Vps } from '@/types/entities'
|
import type { Payment, Vps } from '@/types/entities'
|
||||||
|
import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -85,6 +86,11 @@ function VpsDetailPage() {
|
|||||||
[snapshot, vpsId],
|
[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 overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
|
||||||
|
|
||||||
const customFieldDefs = useMemo(
|
const customFieldDefs = useMemo(
|
||||||
@@ -200,6 +206,16 @@ function VpsDetailPage() {
|
|||||||
<InfoRow label="Хостер" value={provider?.name || '—'} />
|
<InfoRow label="Хостер" value={provider?.name || '—'} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
{customFieldRows.length > 0 ? (
|
{customFieldRows.length > 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
|||||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||||
import { ProjectColorDot } from '@/components/project-color-dot'
|
import { ProjectColorDot } from '@/components/project-color-dot'
|
||||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||||
|
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
@@ -346,6 +347,19 @@ function VpsPage() {
|
|||||||
v.dns || undefined,
|
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',
|
key: 'account',
|
||||||
header: 'Аккаунт',
|
header: 'Аккаунт',
|
||||||
@@ -513,6 +527,10 @@ function VpsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{snapshot?.vpsDomains?.length ? (
|
||||||
|
<UnmatchedDomainsBanner domains={snapshot.vpsDomains} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<QueryState
|
<QueryState
|
||||||
data={snapshot}
|
data={snapshot}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ export interface Settings {
|
|||||||
telegramMessageThreadId?: string
|
telegramMessageThreadId?: string
|
||||||
telegramBotTokenSet?: boolean
|
telegramBotTokenSet?: boolean
|
||||||
customFields?: CustomFieldDef[]
|
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 {
|
export interface ActiveTariff {
|
||||||
@@ -210,4 +231,5 @@ export interface DataSnapshot {
|
|||||||
tariffSyncOptions?: unknown[]
|
tariffSyncOptions?: unknown[]
|
||||||
serverProjects?: ServerProject[]
|
serverProjects?: ServerProject[]
|
||||||
syncLog: SyncLogRow[]
|
syncLog: SyncLogRow[]
|
||||||
|
vpsDomains?: VpsDomain[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
import { asc, eq } from 'drizzle-orm'
|
import { asc, eq } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
appSwitcherConfigSchema,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from '@cfdm/shared/contracts/app-switcher'
|
||||||
import { getDb, schema } from '../index.js'
|
import { getDb, schema } from '../index.js'
|
||||||
|
|
||||||
type Row = typeof schema.settings.$inferSelect
|
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
|
telegramBotTokenSet: boolean
|
||||||
|
integrationTokenSet: boolean
|
||||||
autoConvert: boolean
|
autoConvert: boolean
|
||||||
syncEnabled: boolean
|
syncEnabled: boolean
|
||||||
notifyPaymentExpiryEnabled: boolean
|
notifyPaymentExpiryEnabled: boolean
|
||||||
@@ -13,9 +55,20 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
|
|||||||
notifySyncDigestEnabled: boolean
|
notifySyncDigestEnabled: boolean
|
||||||
notifyVpsDownEnabled: boolean
|
notifyVpsDownEnabled: boolean
|
||||||
webhookEnabled: boolean
|
webhookEnabled: boolean
|
||||||
|
integrationEnabled: boolean
|
||||||
notifyIntervalMinutes: number
|
notifyIntervalMinutes: number
|
||||||
uptimeCheckIntervalMinutes: number
|
uptimeCheckIntervalMinutes: number
|
||||||
customFields: unknown[]
|
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 {
|
function toDto(row: Row | undefined): SettingsDto | undefined {
|
||||||
@@ -28,10 +81,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
customFields = []
|
customFields = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { telegramBotToken, ...rest } = row
|
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
||||||
|
integrationTokenSet: Boolean(integrationToken?.trim()),
|
||||||
autoConvert: Boolean(row.autoConvert),
|
autoConvert: Boolean(row.autoConvert),
|
||||||
syncEnabled: Boolean(row.syncEnabled),
|
syncEnabled: Boolean(row.syncEnabled),
|
||||||
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
||||||
@@ -40,9 +94,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||||
webhookEnabled: Boolean(row.webhookEnabled),
|
webhookEnabled: Boolean(row.webhookEnabled),
|
||||||
|
integrationEnabled: Boolean(row.integrationEnabled),
|
||||||
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||||
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
|
appSwitcher: parseAppSwitcher(appSwitcherJson),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +130,11 @@ interface SettingsInput {
|
|||||||
notifyIntervalMinutes?: number
|
notifyIntervalMinutes?: number
|
||||||
uptimeCheckIntervalMinutes?: number
|
uptimeCheckIntervalMinutes?: number
|
||||||
customFields?: unknown
|
customFields?: unknown
|
||||||
|
appSwitcher?: AppSwitcherConfig
|
||||||
|
integrationToken?: string
|
||||||
|
integrationEnabled?: boolean
|
||||||
|
integrationLastSyncAt?: string
|
||||||
|
cfdmApiUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
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)
|
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
|
||||||
: existing?.uptimeCheckIntervalMinutes ?? 5,
|
: existing?.uptimeCheckIntervalMinutes ?? 5,
|
||||||
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
|
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 {
|
getRow(id: string): Row | undefined {
|
||||||
return getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get()
|
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 {
|
upsert(id: string, input: SettingsInput): SettingsDto {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const existing = this.getRow(id)
|
const existing = this.getRow(id)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { settingsRepository } from './settings.js'
|
|||||||
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
|
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
|
||||||
import { projectsRepository } from './projects.js'
|
import { projectsRepository } from './projects.js'
|
||||||
import { syncLogRepository } from './sync-log.js'
|
import { syncLogRepository } from './sync-log.js'
|
||||||
|
import { vpsDomainsRepository } from './vps-domains.js'
|
||||||
|
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
vps: ReturnType<typeof vpsRepository.list>
|
vps: ReturnType<typeof vpsRepository.list>
|
||||||
@@ -19,6 +20,7 @@ export interface Snapshot {
|
|||||||
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
|
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
|
||||||
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
|
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
|
||||||
syncLog: ReturnType<typeof syncLogRepository.listRecent>
|
syncLog: ReturnType<typeof syncLogRepository.listRecent>
|
||||||
|
vpsDomains: ReturnType<typeof vpsDomainsRepository.list>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSnapshot(): Snapshot {
|
export function getSnapshot(): Snapshot {
|
||||||
@@ -33,6 +35,7 @@ export function getSnapshot(): Snapshot {
|
|||||||
activeTariffs: activeTariffsRepository.list(),
|
activeTariffs: activeTariffsRepository.list(),
|
||||||
tariffSyncOptions: tariffSyncOptionsRepository.list(),
|
tariffSyncOptions: tariffSyncOptionsRepository.list(),
|
||||||
syncLog: syncLogRepository.listRecent(50),
|
syncLog: syncLogRepository.listRecent(50),
|
||||||
|
vpsDomains: vpsDomainsRepository.list(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,4 +50,5 @@ export {
|
|||||||
tariffSyncOptionsRepository,
|
tariffSyncOptionsRepository,
|
||||||
projectsRepository,
|
projectsRepository,
|
||||||
syncLogRepository,
|
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 webhookEnabled INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
|
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes 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[] = [
|
const TABLE_MIGRATIONS: string[] = [
|
||||||
@@ -52,6 +58,21 @@ const TABLE_MIGRATIONS: string[] = [
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
createdAt 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
|
let migrated = false
|
||||||
|
|||||||
@@ -128,6 +128,27 @@ export const settings = sqliteTable('settings', {
|
|||||||
webhookEnabled: integer('webhookEnabled'),
|
webhookEnabled: integer('webhookEnabled'),
|
||||||
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
|
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
|
||||||
uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'),
|
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', {
|
export const notificationLog = sqliteTable('notification_log', {
|
||||||
|
|||||||
@@ -160,7 +160,29 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
webhookUrl TEXT,
|
webhookUrl TEXT,
|
||||||
webhookEnabled INTEGER,
|
webhookEnabled INTEGER,
|
||||||
notifyIntervalMinutes 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 (
|
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 { z } from 'zod'
|
||||||
import { customFieldsSchema } from './custom-fields.js'
|
import { customFieldsSchema } from './custom-fields.js'
|
||||||
|
import { appSwitcherConfigSchema } from './app-switcher.js'
|
||||||
|
|
||||||
export const settingsSchema = z.object({
|
export const settingsSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -23,6 +24,9 @@ export const settingsSchema = z.object({
|
|||||||
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||||
webhookEnabled: z.boolean().optional(),
|
webhookEnabled: z.boolean().optional(),
|
||||||
customFields: customFieldsSchema.optional(),
|
customFields: customFieldsSchema.optional(),
|
||||||
|
appSwitcher: appSwitcherConfigSchema.optional(),
|
||||||
|
integrationToken: z.string().optional(),
|
||||||
|
integrationEnabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Settings = z.infer<typeof settingsSchema>
|
export type Settings = z.infer<typeof settingsSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user