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

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

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 16:45:16 +07:00
co-authored by Cursor
parent 2b01063279
commit 0e9a6a6a21
34 changed files with 1326 additions and 39 deletions
+1 -2
View File
@@ -1,5 +1,4 @@
# Frontend (Vite)
# VITE_API_URL=
# Список приложений для sidebar switcher (JSON, опционально)
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[{"id":"vps-tracker","name":"VPS Tracker","subtitle":"Учёт VPS","url":"http://192.168.100.67:3001","icon":"server"},{"id":"cfdm","name":"CF Domain Manager","subtitle":"Домены","url":"http://192.168.100.67:6363","icon":"cloud"},{"id":"grafana","name":"Grafana","url":"https://grafana.example.com","icon":"chart"}]}
# Публичные URL приложений и integration token — в UI: Настройки → Интеграции
+3 -3
View File
@@ -16,13 +16,13 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getAppSwitcherConfig,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
export function AppSwitcher() {
const { isMobile } = useSidebar()
const config = getAppSwitcherConfig()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
@@ -58,7 +58,7 @@ export function AppSwitcher() {
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{config.menuLabel}
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
@@ -0,0 +1,116 @@
import { useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { z } from 'zod'
import { appSwitcherConfigSchema } from '@cfdm/shared/contracts/app-switcher'
import { Button } from '@cfdm/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config'
const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({
value: icon,
label: icon,
}))
const formSchema = z.object({
menuLabel: z.string().min(1),
apps: appSwitcherConfigSchema.shape.apps,
})
export type AppSwitcherFormValues = z.infer<typeof formSchema>
interface AppSwitcherEditorProps {
defaultValues: AppSwitcherFormValues
onSave: (values: AppSwitcherFormValues) => void
isSaving?: boolean
}
export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) {
const form = useForm<AppSwitcherFormValues>({
resolver: zodResolver(formSchema),
defaultValues,
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<Card>
<CardHeader>
<CardTitle>Связанные приложения</CardTitle>
<CardDescription>URL для переключателя в sidebar и deep links</CardDescription>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit(onSave)(e)}
>
<FieldGroup>
<FormField label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormField>
{fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
<FormField label="ID" htmlFor={`app-id-${index}`}>
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
</FormField>
<FormField label="Название" htmlFor={`app-name-${index}`}>
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
</FormField>
<FormField label="URL" htmlFor={`app-url-${index}`}>
<Input id={`app-url-${index}`} className="md:col-span-2" {...form.register(`apps.${index}.url`)} />
</FormField>
<FormField label="Иконка" htmlFor={`app-icon-${index}`}>
<SelectField
triggerId={`app-icon-${index}`}
value={form.watch(`apps.${index}.icon`)}
onValueChange={(v) =>
form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, {
shouldDirty: true,
})
}
options={ICON_OPTIONS}
/>
</FormField>
<div className="flex items-end justify-end">
<Button
type="button"
variant="outline"
size="icon"
disabled={fields.length <= 1}
onClick={() => remove(index)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
Сохранить приложения
</LoadingButton>
</form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,134 @@
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import type { Settings } from '@/types/entities'
const formSchema = z.object({
cfdmApiUrl: z.string().optional().default(''),
integrationToken: z.string().optional().default(''),
integrationEnabled: z.boolean().default(false),
})
type FormValues = z.infer<typeof formSchema>
function generateToken(): string {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
interface CfdmIntegrationCardProps {
settings?: Settings
onSave: (values: {
cfdmApiUrl?: string
integrationToken?: string
integrationEnabled: boolean
}) => void
isSaving?: boolean
}
export function CfdmIntegrationCard({ settings, onSave, isSaving }: CfdmIntegrationCardProps) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
values: {
cfdmApiUrl: settings?.cfdmApiUrl ?? '',
integrationToken: '',
integrationEnabled: settings?.integrationEnabled === true,
},
})
function handleSubmit(values: FormValues) {
const token = values.integrationToken?.trim()
onSave({
integrationEnabled: values.integrationEnabled,
cfdmApiUrl: values.cfdmApiUrl?.trim() || undefined,
...(token ? { integrationToken: token } : {}),
})
}
return (
<Card>
<CardHeader>
<CardTitle>CF Domain Manager</CardTitle>
<CardDescription>
Приём синхронизации доменов и сервисов из CFDM. Скопируйте токен в настройки CFDM.
</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit(handleSubmit)(e)}>
<FieldGroup>
<FormField label="URL API CFDM" htmlFor="cfdm-api-url">
<Input
id="cfdm-api-url"
placeholder="http://192.168.100.67:6363 (для failover vps_down)"
{...form.register('cfdmApiUrl')}
/>
</FormField>
<FormField label="Integration token" htmlFor="integration-token">
<div className="flex gap-2">
<Input
id="integration-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.integrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Сгенерируйте или вставьте токен'
}
{...form.register('integrationToken')}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const token = generateToken()
form.setValue('integrationToken', token, { shouldDirty: true })
void navigator.clipboard.writeText(token)
toast.success('Токен сгенерирован и скопирован')
}}
>
Сгенерировать
</Button>
</div>
</FormField>
<Controller
control={form.control}
name="integrationEnabled"
render={({ field }) => (
<FormField label="Принимать синхронизацию" htmlFor="integration-enabled">
<SelectField
triggerId="integration-enabled"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormField>
)}
/>
{settings?.integrationLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync: {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<LoadingButton type="submit" className="w-fit" loading={isSaving} disabled={!form.formState.isDirty}>
Сохранить интеграцию
</LoadingButton>
</form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,66 @@
import { ExternalLinkIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Badge } from '@/components/reui/badge'
import { useAppUrl } from '@/hooks/use-app-switcher'
import type { VpsDomain } from '@/types/entities'
interface VpsDomainsCellProps {
domains: VpsDomain[]
}
export function VpsDomainsCell({ domains }: VpsDomainsCellProps) {
const cfdmUrl = useAppUrl('cfdm')
if (domains.length === 0) return <span className="text-muted-foreground"></span>
return (
<div className="flex max-w-xs flex-col gap-1">
{domains.slice(0, 3).map((d) => (
<div key={d.id} className="flex flex-wrap items-center gap-1">
<span className="truncate text-sm">{d.fqdn}</span>
{d.matchStatus !== 'matched' ? (
<Badge variant="warning" className="text-xs">
{d.matchStatus === 'orphaned' ? 'orphan' : 'unmatched'}
</Badge>
) : null}
{cfdmUrl ? (
<a
href={`${cfdmUrl.replace(/\/$/, '')}/services`}
className="text-muted-foreground hover:text-foreground"
title={`Сервис ${d.serviceName} в CFDM`}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3.5" />
</a>
) : null}
</div>
))}
{domains.length > 3 ? (
<span className="text-xs text-muted-foreground">+{domains.length - 3}</span>
) : null}
</div>
)
}
export function UnmatchedDomainsBanner({ domains }: { domains: VpsDomain[] }) {
const unmatched = domains.filter((d) => d.matchStatus === 'unmatched' || !d.vpsId)
if (unmatched.length === 0) return null
return (
<div className="rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<p className="font-medium">Домены без привязки к VPS: {unmatched.length}</p>
<ul className="mt-1 list-inside list-disc text-muted-foreground">
{unmatched.slice(0, 5).map((d) => (
<li key={d.id}>
{d.fqdn} ({d.serviceName})
</li>
))}
</ul>
<Link to="/settings/integrations" className="mt-2 inline-block text-sm underline">
Настройки интеграции
</Link>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
const { data, isLoading } = useQuery(appSwitcherQueryOptions())
return {
config: data ?? DEFAULT_APP_SWITCHER_CONFIG,
isLoading,
}
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config)
}
+1
View File
@@ -73,6 +73,7 @@ function uid(): string {
export const api = {
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
get: <T>(path: string) => fetchApi<T>(`/api/${path.replace(/^\//, '')}`),
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
+7
View File
@@ -79,6 +79,13 @@ export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = getAppSwitcherConfig(),
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(),
): AppSwitcherEntry {
+15
View File
@@ -0,0 +1,15 @@
import { queryOptions } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher'
import { api } from '@/lib/api-client'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher'] as const
export function appSwitcherQueryOptions() {
return queryOptions({
queryKey: appSwitcherQueryKey,
queryFn: () => api.get<AppSwitcherConfig>('/settings/app-switcher'),
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
})
}
+71 -21
View File
@@ -14,7 +14,6 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals'
@@ -25,7 +24,10 @@ import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
const AuthRoute = AuthRouteImport.update({
@@ -52,11 +54,6 @@ const AuthSyncJournalRoute = AuthSyncJournalRouteImport.update({
path: '/sync-journal',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthResourcesRoute = AuthResourcesRouteImport.update({
id: '/resources',
path: '/resources',
@@ -107,11 +104,27 @@ const AuthAccountsRoute = AuthAccountsRouteImport.update({
path: '/accounts',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
id: '/$vpsId',
path: '/$vpsId',
getParentRoute: () => AuthVpsRoute,
} as any)
const AuthSettingsIntegrationsRoute =
AuthSettingsIntegrationsRouteImport.update({
id: '/integrations',
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
id: '/$projectId',
path: '/$projectId',
@@ -120,6 +133,7 @@ const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/accounts': typeof AuthAccountsRoute
'/audit': typeof AuthAuditRoute
'/balance': typeof AuthBalanceRoute
@@ -130,12 +144,13 @@ export interface FileRoutesByFullPath {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -149,17 +164,19 @@ export interface FileRoutesByTo {
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
'/resources': typeof AuthResourcesRoute
'/settings': typeof AuthSettingsRoute
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/settings': typeof AuthSettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/accounts': typeof AuthAccountsRoute
'/_auth/audit': typeof AuthAuditRoute
'/_auth/balance': typeof AuthBalanceRoute
@@ -170,17 +187,19 @@ export interface FileRoutesById {
'/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute
'/_auth/resources': typeof AuthResourcesRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/settings'
| '/accounts'
| '/audit'
| '/balance'
@@ -191,12 +210,13 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/settings'
| '/sync-journal'
| '/tariffs'
| '/vps'
| '/projects/$projectId'
| '/settings/integrations'
| '/vps/$vpsId'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -210,16 +230,18 @@ export interface FileRouteTypes {
| '/renewals'
| '/reports'
| '/resources'
| '/settings'
| '/sync-journal'
| '/tariffs'
| '/vps'
| '/projects/$projectId'
| '/settings/integrations'
| '/vps/$vpsId'
| '/settings'
id:
| '__root__'
| '/'
| '/_auth'
| '/_auth/settings'
| '/_auth/accounts'
| '/_auth/audit'
| '/_auth/balance'
@@ -230,12 +252,13 @@ export interface FileRouteTypes {
| '/_auth/renewals'
| '/_auth/reports'
| '/_auth/resources'
| '/_auth/settings'
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/vps'
| '/_auth/projects/$projectId'
| '/_auth/settings/integrations'
| '/_auth/vps/$vpsId'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -280,13 +303,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSyncJournalRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/resources': {
id: '/_auth/resources'
path: '/resources'
@@ -357,6 +373,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAccountsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings/': {
id: '/_auth/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/vps/$vpsId': {
id: '/_auth/vps/$vpsId'
path: '/$vpsId'
@@ -364,6 +394,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
parentRoute: typeof AuthVpsRoute
}
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
fullPath: '/settings/integrations'
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/projects/$projectId': {
id: '/_auth/projects/$projectId'
path: '/$projectId'
@@ -374,6 +411,19 @@ declare module '@tanstack/react-router' {
}
}
interface AuthSettingsRouteRouteChildren {
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
const AuthSettingsRouteRouteWithChildren =
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
interface AuthProjectsRouteChildren {
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
}
@@ -398,6 +448,7 @@ const AuthVpsRouteWithChildren =
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAccountsRoute: typeof AuthAccountsRoute
AuthAuditRoute: typeof AuthAuditRoute
AuthBalanceRoute: typeof AuthBalanceRoute
@@ -408,13 +459,13 @@ interface AuthRouteChildren {
AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute
AuthResourcesRoute: typeof AuthResourcesRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren
}
const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthAccountsRoute: AuthAccountsRoute,
AuthAuditRoute: AuthAuditRoute,
AuthBalanceRoute: AuthBalanceRoute,
@@ -425,7 +476,6 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute,
AuthResourcesRoute: AuthResourcesRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthVpsRoute: AuthVpsRouteWithChildren,
@@ -8,8 +8,6 @@ import { useMemo, useCallback } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
@@ -35,7 +33,7 @@ import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { NotificationLogRow, Settings } from '@/types/entities'
export const Route = createFileRoute('/_auth/settings')({
export const Route = createFileRoute('/_auth/settings/')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsPage,
@@ -316,12 +314,8 @@ function SettingsPage() {
)
return (
<PageShell>
<PageHeader
title="Настройки"
description="Базовая валюта, курсы, синк, Telegram"
actions={backupActions}
/>
<>
<div className="flex flex-wrap gap-2">{backupActions}</div>
<QueryState
data={snapshot}
isLoading={isLoading}
@@ -604,6 +598,6 @@ function SettingsPage() {
</form>
)}
</QueryState>
</PageShell>
</>
)
}
@@ -0,0 +1,60 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
import { api } from '@/lib/api-client'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor'
import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card'
import type { Settings } from '@/types/entities'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage,
})
function SettingsIntegrationsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const current = snapshot?.settings?.[0] as Settings | undefined
const saveMut = useMutation({
mutationFn: (patch: Partial<Settings> & { appSwitcher?: Settings['appSwitcher'] }) =>
api.update<Settings>('settings', current?.id ?? 'settings-main', patch),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
toast.success('Настройки интеграции сохранены')
},
onError: () => toast.error('Не удалось сохранить'),
})
return (
<QueryState
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={2} />}
>
{() => (
<div className="flex flex-col gap-4">
<AppSwitcherEditor
defaultValues={current?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<CfdmIntegrationCard
settings={current}
isSaving={saveMut.isPending}
onSave={(patch) => saveMut.mutate(patch)}
/>
</div>
)}
</QueryState>
)
}
@@ -0,0 +1,49 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { cn } from '@cfdm/ui/lib/utils'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export const Route = createFileRoute('/_auth/settings')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: SettingsLayout,
})
const TABS = [
{ to: '/settings', label: 'Общие', exact: true },
{ to: '/settings/integrations', label: 'Интеграции', exact: false },
] as const
function SettingsLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<PageHeader title="Настройки" description="Параметры приложения и связи с другими сервисами" />
<nav className="flex gap-1 border-b pb-0">
{TABS.map((tab) => {
const active = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.to}
to={tab.to}
className={cn(
'rounded-t-md px-4 py-2 text-sm font-medium transition-colors',
active
? 'border border-b-0 bg-background text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<Outlet />
</PageShell>
)
}
+16
View File
@@ -41,6 +41,7 @@ import {
formatCustomFieldValue,
} from '@/lib/custom-fields'
import type { Payment, Vps } from '@/types/entities'
import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell'
export const Route = createFileRoute('/_auth/vps/$vpsId')({
loader: ({ context: { queryClient } }) =>
@@ -85,6 +86,11 @@ function VpsDetailPage() {
[snapshot, vpsId],
)
const vpsDomains = useMemo(
() => (snapshot?.vpsDomains ?? []).filter((d) => d.vpsId === vpsId),
[snapshot, vpsId],
)
const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
const customFieldDefs = useMemo(
@@ -200,6 +206,16 @@ function VpsDetailPage() {
<InfoRow label="Хостер" value={provider?.name || '—'} />
</CardContent>
</Card>
{vpsDomains.length > 0 ? (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="text-base">Домены (CFDM)</CardTitle>
</CardHeader>
<CardContent>
<VpsDomainsCell domains={vpsDomains} />
</CardContent>
</Card>
) : null}
</div>
{customFieldRows.length > 0 ? (
<Card>
+18
View File
@@ -31,6 +31,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
import { HealthModeBanner } from '@/components/health-mode-banner'
import { ProjectColorDot } from '@/components/project-color-dot'
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
import type { Vps } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
@@ -346,6 +347,19 @@ function VpsPage() {
v.dns || undefined,
),
},
{
key: 'domains',
header: 'Домены',
icon: GlobeIcon,
sortValue: (v) =>
(snapshot?.vpsDomains ?? [])
.filter((d) => d.vpsId === v.id)
.map((d) => d.fqdn)
.join(', '),
cell: (v) => (
<VpsDomainsCell domains={(snapshot?.vpsDomains ?? []).filter((d) => d.vpsId === v.id)} />
),
},
{
key: 'account',
header: 'Аккаунт',
@@ -513,6 +527,10 @@ function VpsPage() {
}
/>
{snapshot?.vpsDomains?.length ? (
<UnmatchedDomainsBanner domains={snapshot.vpsDomains} />
) : null}
<QueryState
data={snapshot}
isLoading={isLoading}
+22
View File
@@ -129,6 +129,27 @@ export interface Settings {
telegramMessageThreadId?: string
telegramBotTokenSet?: boolean
customFields?: CustomFieldDef[]
appSwitcher?: import('@cfdm/shared/contracts/app-switcher').AppSwitcherConfig
integrationEnabled?: boolean
integrationTokenSet?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
}
export interface VpsDomain {
id: string
vpsId: string | null
fqdn: string
zoneName: string
hostname: string
serviceName: string
serviceSlug: string
cfdmServiceId: number
cfdmBindingId: number
source: string
matchStatus: 'matched' | 'unmatched' | 'orphaned'
targetIps?: string | null
syncedAt: string
}
export interface ActiveTariff {
@@ -210,4 +231,5 @@ export interface DataSnapshot {
tariffSyncOptions?: unknown[]
serverProjects?: ServerProject[]
syncLog: SyncLogRow[]
vpsDomains?: VpsDomain[]
}