Синхронизация 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:
@@ -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