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
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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user