feat: интеграция с VPS Tracker
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3m36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

Исходящий sync bindings после updateConfig, настройки в app_settings, страница Интеграции, приём событий vps_down для DNS failover.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 16:45:17 +07:00
co-authored by Cursor
parent 859918fae2
commit 96783386e0
32 changed files with 2116 additions and 243 deletions
+2
View File
@@ -5,6 +5,7 @@ import {
FolderTreeIcon,
ServerIcon,
ShieldCheckIcon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
@@ -30,6 +31,7 @@ const infrastructureNav = [
const operationsNav = [
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
{ to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false },
] as const
function NavSection({
+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,110 @@
import { useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared'
import { Button } from '@cfdm/ui/components/button'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } 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,
}))
export type AppSwitcherFormValues = AppSwitcherConfig
interface AppSwitcherEditorProps {
defaultValues: AppSwitcherFormValues
onSave: (values: AppSwitcherFormValues) => void
isSaving?: boolean
}
export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) {
const form = useForm({
resolver: zodResolver(appSwitcherConfigSchema),
defaultValues,
})
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>Связанные приложения</AppCardTitle>
<AppCardDescription>URL для переключателя в sidebar</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<FieldGroup>
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormFieldSimple>
{fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
<FormFieldSimple label="ID" htmlFor={`app-id-${index}`}>
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
</FormFieldSimple>
<FormFieldSimple label="Название" htmlFor={`app-name-${index}`}>
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
</FormFieldSimple>
<FormFieldSimple label="URL" htmlFor={`app-url-${index}`} className="md:col-span-2">
<Input id={`app-url-${index}`} {...form.register(`apps.${index}.url`)} />
</FormFieldSimple>
<FormFieldSimple label="Иконка" htmlFor={`app-icon-${index}`}>
<SelectField
triggerId={`app-icon-${index}`}
value={form.watch(`apps.${index}.icon`)}
onValueChange={(v: string | null) =>
form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, {
shouldDirty: true,
})
}
options={ICON_OPTIONS}
/>
</FormFieldSimple>
<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" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить приложения
</LoadingButton>
</form>
</AppCardContent>
</AppCard>
)
}
@@ -0,0 +1,126 @@
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { AppButton } from '@/components/app-button'
import { api } from '@/lib/api-client'
const formSchema = z.object({
vpsTrackerUrl: z.string().optional().default(''),
vpsTrackerIntegrationToken: z.string().optional().default(''),
vpsTrackerSyncEnabled: z.boolean().default(false),
})
type FormValues = z.infer<typeof formSchema>
export interface AppSettingsView {
vpsTrackerUrl: string
vpsTrackerIntegrationTokenSet: boolean
vpsTrackerSyncEnabled: boolean
vpsTrackerLastSyncAt: string | null
}
interface VpsTrackerIntegrationCardProps {
settings?: AppSettingsView
onSave: (values: FormValues) => void
isSaving?: boolean
}
export function VpsTrackerIntegrationCard({
settings,
onSave,
isSaving,
}: VpsTrackerIntegrationCardProps) {
const form = useForm({
resolver: zodResolver(formSchema),
values: {
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
vpsTrackerIntegrationToken: '',
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
},
})
async function handleTest() {
const result = await api.post<{ ok: boolean; error?: string }>(
'/api/v1/settings/vps-tracker/test',
)
if (result.ok) toast.success('Связь с VPS Tracker установлена')
else toast.error(result.error ?? 'Ошибка проверки')
}
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>VPS Tracker</AppCardTitle>
<AppCardDescription>
Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001).
</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}>
<FieldGroup>
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
<Input
id="vps-url"
placeholder="http://192.168.100.67:3001"
{...form.register('vpsTrackerUrl')}
/>
</FormFieldSimple>
<FormFieldSimple label="Integration token" htmlFor="vps-token">
<Input
id="vps-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.vpsTrackerIntegrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Тот же токен, что в VPS Tracker'
}
{...form.register('vpsTrackerIntegrationToken')}
/>
</FormFieldSimple>
<Controller
control={form.control}
name="vpsTrackerSyncEnabled"
render={({ field }) => (
<FormFieldSimple label="Синхронизация включена" htmlFor="vps-sync">
<SelectField
triggerId="vps-sync"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormFieldSimple>
)}
/>
{settings?.vpsTrackerLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync:{' '}
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<div className="flex flex-wrap gap-2">
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<AppButton type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</AppButton>
</div>
</form>
</AppCardContent>
</AppCard>
)
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from 'react'
import type { SelectRootProps } from '@base-ui/react/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { cn } from '@cfdm/ui/lib/utils'
export interface SelectOption {
value: string
label: React.ReactNode
}
interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'value' | 'onValueChange'> {
options: SelectOption[]
placeholder?: string
triggerClassName?: string
triggerId?: string
size?: 'sm' | 'default'
value?: string | null
onValueChange?: (value: string | null) => void
invalid?: boolean
'aria-label'?: string
}
export function SelectField({
options,
placeholder,
triggerClassName,
triggerId,
size = 'default',
value,
onValueChange,
invalid,
'aria-label': ariaLabel,
...props
}: SelectFieldProps) {
const items = React.useMemo(
() => options.map((o) => ({ value: o.value, label: o.label })),
[options],
)
return (
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
<SelectTrigger
id={triggerId}
size={size}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
className={cn('w-full', triggerClassName)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cfdm/shared'
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)
}
+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'
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>('/api/v1/settings/app-switcher'),
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
})
}
+71
View File
@@ -15,7 +15,10 @@ import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
@@ -49,11 +52,27 @@ const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
path: '/certificates',
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 AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
id: '/domains/',
path: '/domains/',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsIntegrationsRoute =
AuthSettingsIntegrationsRouteImport.update({
id: '/integrations',
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
id: '/$groupId',
path: '/$groupId',
@@ -74,11 +93,14 @@ const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/login': typeof LoginRoute
'/settings': typeof AuthSettingsRouteRouteWithChildren
'/certificates': typeof AuthCertificatesRoute
'/groups': typeof AuthGroupsRouteWithChildren
'/services': typeof AuthServicesRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains/': typeof AuthDomainsIndexRoute
'/settings/': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
}
@@ -89,7 +111,9 @@ export interface FileRoutesByTo {
'/services': typeof AuthServicesRoute
'/': typeof AuthIndexRoute
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/domains': typeof AuthDomainsIndexRoute
'/settings': typeof AuthSettingsIndexRoute
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
}
@@ -97,12 +121,15 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteWithChildren
'/login': typeof LoginRoute
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
'/_auth/certificates': typeof AuthCertificatesRoute
'/_auth/groups': typeof AuthGroupsRouteWithChildren
'/_auth/services': typeof AuthServicesRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/domains/': typeof AuthDomainsIndexRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
}
@@ -111,11 +138,14 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
| '/settings'
| '/certificates'
| '/groups'
| '/services'
| '/groups/$groupId'
| '/settings/integrations'
| '/domains/'
| '/settings/'
| '/domains/$domainId/dns'
| '/domains/$domainId/'
fileRoutesByTo: FileRoutesByTo
@@ -126,19 +156,24 @@ export interface FileRouteTypes {
| '/services'
| '/'
| '/groups/$groupId'
| '/settings/integrations'
| '/domains'
| '/settings'
| '/domains/$domainId/dns'
| '/domains/$domainId'
id:
| '__root__'
| '/_auth'
| '/login'
| '/_auth/settings'
| '/_auth/certificates'
| '/_auth/groups'
| '/_auth/services'
| '/_auth/'
| '/_auth/groups/$groupId'
| '/_auth/settings/integrations'
| '/_auth/domains/'
| '/_auth/settings/'
| '/_auth/domains/$domainId/dns'
| '/_auth/domains/$domainId/'
fileRoutesById: FileRoutesById
@@ -192,6 +227,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthCertificatesRouteImport
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/domains/': {
id: '/_auth/domains/'
path: '/domains'
@@ -199,6 +248,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDomainsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
fullPath: '/settings/integrations'
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/groups/$groupId': {
id: '/_auth/groups/$groupId'
path: '/$groupId'
@@ -223,6 +279,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 AuthGroupsRouteChildren {
AuthGroupsGroupIdRoute: typeof AuthGroupsGroupIdRoute
}
@@ -236,6 +305,7 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
)
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthCertificatesRoute: typeof AuthCertificatesRoute
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
AuthServicesRoute: typeof AuthServicesRoute
@@ -246,6 +316,7 @@ interface AuthRouteChildren {
}
const AuthRouteChildren: AuthRouteChildren = {
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
AuthCertificatesRoute: AuthCertificatesRoute,
AuthGroupsRoute: AuthGroupsRouteWithChildren,
AuthServicesRoute: AuthServicesRoute,
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/settings/')({
beforeLoad: () => {
throw redirect({ to: '/settings/integrations' })
},
})
@@ -0,0 +1,74 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared'
import { api } from '@/lib/api-client'
import { QueryState } from '@/components/query-state'
import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor'
import {
VpsTrackerIntegrationCard,
type AppSettingsView,
} from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
type SettingsResponse = AppSettingsView & {
id: string
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG
}
export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage,
})
function SettingsIntegrationsPage() {
const queryClient = useQueryClient()
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
})
const saveMut = useMutation({
mutationFn: (patch: AppSettingsPatch) =>
api.patch<SettingsResponse>('/api/v1/settings', patch),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['app-settings'] })
await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
toast.success('Настройки сохранены')
},
onError: () => toast.error('Не удалось сохранить'),
})
return (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
>
{data ? (
<div className="flex flex-col gap-4">
<AppSwitcherEditor
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
</div>
) : null}
</QueryState>
)
}
@@ -0,0 +1,40 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsLayout,
})
const TABS = [{ to: '/settings/integrations', label: 'Интеграции' }] 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 = 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>
)
}