Синхронизация 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 { 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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user