Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d695f2a4 |
@@ -8,7 +8,6 @@ import {
|
||||
Settings,
|
||||
BookText,
|
||||
KeyRound,
|
||||
ServerCog,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -97,8 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Система',
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настройки
|
||||
|
||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
||||
setApiToken(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||
window.location.assign('/settings?tab=connection&reason=token-required')
|
||||
window.location.assign('/settings?tab=ui&reason=token-required')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,10 +190,10 @@ export function NavUser() {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
nativeButton={false}
|
||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||
>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки UI
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
{authOn ? (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { FrameDataGrid } from './frame-data-grid'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export { SettingsShell } from './settings-shell'
|
||||
|
||||
@@ -1,113 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
|
||||
export interface SettingsTabConfig {
|
||||
id: string
|
||||
to: '/settings' | '/tenant-settings'
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
||||
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||
{
|
||||
id: 'ui',
|
||||
to: '/settings',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'tenant',
|
||||
to: '/tenant-settings',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
title?: string
|
||||
description?: string
|
||||
tabs?: SettingsTabConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings shell — Frame surface, settings-16 left rail.
|
||||
* Rail stacks above content until the page container is wide enough (no viewport-only squeeze).
|
||||
* @see https://reui.io/preview/base/settings-16
|
||||
* @see https://reui.io/preview/base/settings-3
|
||||
* Settings layout — page chrome only.
|
||||
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description,
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const headerDescription =
|
||||
description ??
|
||||
(pathname.startsWith('/tenant-settings')
|
||||
? 'Глобальные параметры BIRD и плоскости управления'
|
||||
: 'Подключение UI и параметры плоскости управления')
|
||||
|
||||
export function SettingsShell() {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="@container mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-5">
|
||||
<PageHeader title={title} description={headerDescription} />
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-5 @3xl:flex-row @3xl:items-start">
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className="scrollbar-none flex min-w-0 gap-1 overflow-x-auto @3xl:w-44 @3xl:shrink-0 @3xl:flex-col @3xl:overflow-visible"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
const className = cn(
|
||||
'flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'@3xl:w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)
|
||||
if (tab.to === '/tenant-settings') {
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to="/tenant-settings"
|
||||
search={{ tab: 'bird' }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={className}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={className}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
REVISION_SETTING_KEYS,
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsKeys,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
|
||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
||||
] as const
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Локальный IPv4',
|
||||
bird_local_ipv6: 'Локальный IPv6',
|
||||
bird_local_asn: 'Локальный ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
||||
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
||||
peer_discovery_require_external: 'Только external ASN',
|
||||
}
|
||||
|
||||
const BIRD_BOOL_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||
peer_discovery_enabled:
|
||||
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
|
||||
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
|
||||
}
|
||||
|
||||
export function BirdSettingsTab() {
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||
|
||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (partitioned) {
|
||||
setBirdForm({ ...partitioned.bird })
|
||||
setRevisionForm({ ...partitioned.revision })
|
||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Параметры сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
function saveBird() {
|
||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||
}
|
||||
function saveRevision() {
|
||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||
}
|
||||
function saveRuntimeLogs() {
|
||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="BIRD control plane"
|
||||
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
|
||||
footer={
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="BIRD"
|
||||
description="Параметры демона и автообнаружения пиров."
|
||||
>
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key]}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
stacked
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectMenu
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
className="w-full min-w-0"
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Retention"
|
||||
description="Срок хранения ревизий в минутах."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Retention"
|
||||
description="Сколько минут хранить ревизии конфигурации."
|
||||
labelFor="revision_retention_minutes"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Авто-очистка"
|
||||
description="Расписание и лимиты файловых логов runtime."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Авто-очистка"
|
||||
description="Включить периодическую очистку файловых логов."
|
||||
stacked
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Макс. размер файла"
|
||||
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||
labelFor="runtime_logs_max_file_mb"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Расписание"
|
||||
description="Cron-выражение для авто-очистки."
|
||||
labelFor="runtime_logs_auto_schedule"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Режим очистки"
|
||||
description="Обнулить файл или удалить его."
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<FrameDataGrid
|
||||
title="Дополнительные параметры"
|
||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'tenant-settings',
|
||||
to: '/tenant-settings' as const,
|
||||
to: '/settings' as const,
|
||||
search: { tab: 'bird' as const },
|
||||
title: 'Параметры арендатора',
|
||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
@@ -32,6 +33,7 @@ const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'access',
|
||||
to: '/access' as const,
|
||||
search: undefined,
|
||||
title: 'Права доступа',
|
||||
description: 'API-ключи tenant, роли и управление доступом.',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
@@ -40,6 +42,7 @@ const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
to: '/monitoring' as const,
|
||||
search: undefined,
|
||||
title: 'Мониторинг',
|
||||
description: 'Метрики, состояние jobs и observability control plane.',
|
||||
icon: <ActivityIcon aria-hidden="true" />,
|
||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="shrink-0 justify-end self-center">
|
||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
section.to === '/settings' ? (
|
||||
<Link to="/settings" search={{ tab: 'bird' }} />
|
||||
) : (
|
||||
<Link to={section.to} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
|
||||
@@ -1,45 +1,138 @@
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@evobgp/ui/components/tabs'
|
||||
|
||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||
import { BirdSettingsTab } from './bird-settings-tab'
|
||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||
import { SessionSettingsTab } from './session-settings-tab'
|
||||
import {
|
||||
SETTINGS_TAB_ITEMS,
|
||||
type SettingsTab,
|
||||
type SettingsSection,
|
||||
type SettingsTabItem,
|
||||
} from './settings-tabs-data'
|
||||
|
||||
function UiSettingsPanel({ tokenRequired }: { tokenRequired: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
<SessionSettingsTab />
|
||||
<AppearanceSettingsTab />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsNavigation({
|
||||
isMobile,
|
||||
activeValue,
|
||||
items,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
activeValue: string
|
||||
items: SettingsTabItem[]
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', isMobile ? 'w-full' : 'w-40 shrink-0')}>
|
||||
{isMobile ? (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||
{items.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||
{items.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
'w-full justify-start gap-3 px-3 py-1.5 shadow-none',
|
||||
activeValue === tab.value ? 'bg-muted!' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified settings — settings-7 AccountSettings 1:1 (header + vertical Tabs).
|
||||
* Surface of cards stays Frame (`SettingsCard`), not shadcn Card.
|
||||
* @see https://reui.io/preview/base/settings-7
|
||||
* @see https://reui.io/blocks
|
||||
* @see https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function SettingsPageShell({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tokenRequired,
|
||||
showBird,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
activeTab: SettingsSection
|
||||
onTabChange: (tab: SettingsSection) => void
|
||||
tokenRequired: boolean
|
||||
showBird: boolean
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const items = showBird
|
||||
? SETTINGS_TAB_ITEMS
|
||||
: SETTINGS_TAB_ITEMS.filter((tab) => tab.value === 'ui')
|
||||
const resolvedTab = activeTab === 'bird' && showBird ? 'bird' : 'ui'
|
||||
|
||||
return (
|
||||
<BadgeTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
>
|
||||
<TabsContent value="connection" className="mt-0">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
<TabsContent value="session" className="mt-0">
|
||||
<SessionSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="appearance" className="mt-0">
|
||||
<AppearanceSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sections" className="mt-0">
|
||||
<SectionsSettingsTab />
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={resolvedTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||
className="w-full gap-4 lg:gap-8"
|
||||
>
|
||||
<SettingsNavigation
|
||||
isMobile={isMobile}
|
||||
activeValue={resolvedTab}
|
||||
items={items}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<TabsContent value="ui" className="mt-0">
|
||||
<UiSettingsPanel tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
{showBird ? (
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<BirdSettingsTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
PaletteIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||
export type SettingsSection = 'ui' | 'bird'
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: SettingsTab
|
||||
value: SettingsSection
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: 'connection',
|
||||
label: 'Подключение',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
value: 'ui',
|
||||
label: 'Настройки UI',
|
||||
icon: <SettingsIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Сессия',
|
||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'appearance',
|
||||
label: 'Оформление',
|
||||
icon: <PaletteIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'sections',
|
||||
label: 'Разделы',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
value: 'bird',
|
||||
label: 'Настройки BIRD',
|
||||
icon: <ServerCogIcon aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||
if (
|
||||
value === 'session' ||
|
||||
value === 'appearance' ||
|
||||
value === 'sections'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return 'connection'
|
||||
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||
|
||||
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||
return 'ui'
|
||||
}
|
||||
|
||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Unified `/settings` — UI (`settings:read`) or BIRD (`tenant_settings:admin`). */
|
||||
export function canOpenSettings(): boolean {
|
||||
return can('bgp:settings:read') || can('bgp:tenant_settings:admin')
|
||||
}
|
||||
|
||||
const FALLBACK_PATH = '/dashboard'
|
||||
|
||||
/** First path in the sidebar the current user may open. */
|
||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
||||
'/schedule',
|
||||
'/monitoring',
|
||||
'/access',
|
||||
'/tenant-settings',
|
||||
'/settings',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
||||
if (!raw || !normalizeApiToken(raw)) {
|
||||
throw redirect({
|
||||
to: '/settings',
|
||||
search: { tab: 'connection', reason: 'token-required' },
|
||||
search: { tab: 'ui', reason: 'token-required' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
||||
|
||||
const settingsSearchSchema = z.object({
|
||||
tab: z
|
||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
||||
.catch('connection'),
|
||||
reason: z.enum(['token-required']).optional(),
|
||||
})
|
||||
import {
|
||||
parseSettingsSection,
|
||||
type SettingsSection,
|
||||
} from '@/components/settings/settings-tabs-data'
|
||||
import { can } from '@/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||
component: SettingsComponent,
|
||||
validateSearch: (search) => settingsSearchSchema.parse(search),
|
||||
validateSearch: (search: Record<string, unknown>): {
|
||||
tab: SettingsSection
|
||||
reason?: 'token-required'
|
||||
} => ({
|
||||
tab: parseSettingsSection(search.tab),
|
||||
...(search.reason === 'token-required'
|
||||
? { reason: 'token-required' as const }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
|
||||
function SettingsComponent() {
|
||||
const navigate = Route.useNavigate()
|
||||
const { tab, reason } = Route.useSearch()
|
||||
const tokenRequired = reason === 'token-required'
|
||||
const showBird = !tokenRequired && can('bgp:tenant_settings:admin')
|
||||
const activeTab: SettingsSection =
|
||||
tokenRequired || (tab === 'bird' && !showBird) ? 'ui' : tab
|
||||
|
||||
function handleTabChange(nextTab: SettingsTab) {
|
||||
function handleTabChange(nextTab: SettingsSection) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
tab: nextTab,
|
||||
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
||||
|
||||
return (
|
||||
<SettingsPageShell
|
||||
activeTab={tab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tokenRequired={tokenRequired}
|
||||
showBird={showBird}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,377 +1,8 @@
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { FrameDataGrid } from '@/components/reui-kit'
|
||||
import { SelectMenu } from '@/components/select-field'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
|
||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
REVISION_SETTING_KEYS,
|
||||
RUNTIME_LOGS_SETTING_KEYS,
|
||||
buildPayload,
|
||||
partitionSettings,
|
||||
settingsKeys,
|
||||
settingsQueryOptions,
|
||||
type BirdSettingKey,
|
||||
} from '@/queries/settings'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||
component: TenantSettingsComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
||||
? search.tab
|
||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
||||
}),
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
const RUNTIME_LOGS_ENABLED_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const RUNTIME_LOGS_MODE_ITEMS = [
|
||||
{ value: 'truncate', label: 'обнулить (truncate)' },
|
||||
{ value: 'delete', label: 'удалить файл (delete)' },
|
||||
] as const
|
||||
|
||||
const BIRD_LABELS: Record<BirdSettingKey, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Локальный IPv4',
|
||||
bird_local_ipv6: 'Локальный IPv6',
|
||||
bird_local_asn: 'Локальный ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6',
|
||||
peer_discovery_enabled: 'Автообнаружение пиров',
|
||||
peer_discovery_ranges_v4: 'Discovery CIDR IPv4',
|
||||
peer_discovery_ranges_v6: 'Discovery CIDR IPv6',
|
||||
peer_discovery_require_external: 'Только external ASN',
|
||||
}
|
||||
|
||||
const BIRD_BOOL_ITEMS = [
|
||||
{ value: 'true', label: 'Вкл' },
|
||||
{ value: 'false', label: 'Выкл' },
|
||||
] as const
|
||||
|
||||
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
|
||||
peer_discovery_enabled:
|
||||
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.',
|
||||
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
|
||||
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
|
||||
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)',
|
||||
}
|
||||
|
||||
function TenantSettingsComponent() {
|
||||
const search = useSearch({ from: '/_auth/_settings/tenant-settings' })
|
||||
const navigate = Route.useNavigate()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const qc = useQueryClient()
|
||||
|
||||
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
|
||||
|
||||
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
|
||||
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
|
||||
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (partitioned) {
|
||||
setBirdForm({ ...partitioned.bird })
|
||||
setRevisionForm({ ...partitioned.revision })
|
||||
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: (payload: Record<string, string | number | boolean>) =>
|
||||
apiMutate('/v1/settings', 'PATCH', payload),
|
||||
onSuccess: () => {
|
||||
toast.success('Параметры сохранены')
|
||||
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
function saveBird() {
|
||||
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
|
||||
}
|
||||
function saveRevision() {
|
||||
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
|
||||
}
|
||||
function saveRuntimeLogs() {
|
||||
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
navigate({
|
||||
search: { tab: tab as 'bird' | 'revision' | 'runtime-logs' | 'additional' },
|
||||
})
|
||||
}
|
||||
items={[
|
||||
{ value: 'bird', label: 'BIRD' },
|
||||
{ value: 'revision', label: 'Ревизии' },
|
||||
{ value: 'runtime-logs', label: 'Файловые логи' },
|
||||
{ value: 'additional', label: 'Дополнительно' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<SettingsCard
|
||||
title="BIRD control plane"
|
||||
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
|
||||
footer={
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="BIRD"
|
||||
description="Параметры демона и автообнаружения пиров."
|
||||
>
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key]}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
stacked
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectMenu
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
className="w-full min-w-0"
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<SettingsCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Retention"
|
||||
description="Срок хранения ревизий в минутах."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Retention"
|
||||
description="Сколько минут хранить ревизии конфигурации."
|
||||
labelFor="revision_retention_minutes"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
...s,
|
||||
revision_retention_minutes: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<SettingsCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Авто-очистка"
|
||||
description="Расписание и лимиты файловых логов runtime."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Авто-очистка"
|
||||
description="Включить периодическую очистку файловых логов."
|
||||
stacked
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_enabled: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Макс. размер файла"
|
||||
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||
labelFor="runtime_logs_max_file_mb"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Расписание"
|
||||
description="Cron-выражение для авто-очистки."
|
||||
labelFor="runtime_logs_auto_schedule"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Режим очистки"
|
||||
description="Обнулить файл или удалить его."
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_mode: v,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" className="mt-0">
|
||||
<FrameDataGrid
|
||||
title="Дополнительные параметры"
|
||||
description="Параметры вне стандартных групп (только чтение — изменяются через API)"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned?.additional ?? []}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
empty={(partitioned?.additional ?? []).length === 0}
|
||||
emptyTitle="Нет дополнительных параметров"
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
<SettingsKvGrid
|
||||
items={items}
|
||||
isLoading={settingsQ.isFetching && !settingsQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</FrameDataGrid>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ function AccessComponent() {
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
search={{ tab: 'ui' }}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
настройках
|
||||
|
||||
@@ -258,7 +258,7 @@ function MonitoringComponent() {
|
||||
<IllustratedEmptyState
|
||||
icon={FileText}
|
||||
title="Файловые логи"
|
||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
|
||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
|
||||
/>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-4
@@ -118,10 +118,10 @@ EvoBGP управляет генерацией и применением BGP-к
|
||||
|
||||
| Маршрут | Назначение |
|
||||
|---------|------------|
|
||||
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
||||
| `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
|
||||
| `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
|
||||
| `/network` → Control plane | Краткая сводка BIRD. |
|
||||
| `/operations` | Ревизии, diff, jobs. |
|
||||
|
||||
### Web UI: файловые runtime-логи
|
||||
|
||||
|
||||
Reference in New Issue
Block a user