refactor(web): unify settings navigation and update tab structure
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 55s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m6s
Refactored the settings components to unify navigation between UI and BIRD settings. Updated tab structures to streamline access and improve user experience. Adjusted routing and search parameters to reflect the new tab organization, ensuring a cohesive interface. Removed legacy tenant settings references and enhanced the settings page layout for clarity and usability.
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -97,8 +96,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Система',
|
label: 'Система',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AppsMenu() {
|
|||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
className="justify-center text-sm font-medium"
|
className="justify-center text-sm font-medium"
|
||||||
>
|
>
|
||||||
Настройки
|
Настройки
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function NavUser() {
|
|||||||
setApiToken(null)
|
setApiToken(null)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
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>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
||||||
>
|
>
|
||||||
<SettingsIcon aria-hidden />
|
<SettingsIcon aria-hidden />
|
||||||
Настройки UI
|
Настройки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{authOn ? (
|
{authOn ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
|||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { FrameDataGrid } from './frame-data-grid'
|
export { FrameDataGrid } from './frame-data-grid'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
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 { Outlet } from '@tanstack/react-router'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
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.
|
* Settings layout — page chrome only.
|
||||||
* Rail stacks above content until the page container is wide enough (no viewport-only squeeze).
|
* Side-tab rail lives in `SettingsPageShell` (settings-7 AccountSettings).
|
||||||
* @see https://reui.io/preview/base/settings-16
|
* @see https://reui.io/preview/base/settings-7
|
||||||
* @see https://reui.io/preview/base/settings-3
|
* @see https://reui.io/blocks
|
||||||
*/
|
*/
|
||||||
export function SettingsShell({
|
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 и параметры плоскости управления')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="@container mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-5">
|
<Outlet />
|
||||||
<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>
|
|
||||||
</PageShell>
|
</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 = [
|
const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
id: 'tenant-settings',
|
id: 'tenant-settings',
|
||||||
to: '/tenant-settings' as const,
|
to: '/settings' as const,
|
||||||
|
search: { tab: 'bird' as const },
|
||||||
title: 'Параметры арендатора',
|
title: 'Параметры арендатора',
|
||||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
@@ -32,6 +33,7 @@ const ADMIN_SECTIONS = [
|
|||||||
{
|
{
|
||||||
id: 'access',
|
id: 'access',
|
||||||
to: '/access' as const,
|
to: '/access' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Права доступа',
|
title: 'Права доступа',
|
||||||
description: 'API-ключи tenant, роли и управление доступом.',
|
description: 'API-ключи tenant, роли и управление доступом.',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
@@ -40,6 +42,7 @@ const ADMIN_SECTIONS = [
|
|||||||
{
|
{
|
||||||
id: 'monitoring',
|
id: 'monitoring',
|
||||||
to: '/monitoring' as const,
|
to: '/monitoring' as const,
|
||||||
|
search: undefined,
|
||||||
title: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Метрики, состояние jobs и observability control plane.',
|
description: 'Метрики, состояние jobs и observability control plane.',
|
||||||
icon: <ActivityIcon aria-hidden="true" />,
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
@@ -76,7 +79,17 @@ export function SectionsSettingsTab() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemActions className="shrink-0 justify-end self-center">
|
<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>
|
</Button>
|
||||||
</ItemActions>
|
</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 { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { BirdSettingsTab } from './bird-settings-tab'
|
||||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
|
||||||
import { SessionSettingsTab } from './session-settings-tab'
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
import {
|
import {
|
||||||
SETTINGS_TAB_ITEMS,
|
SETTINGS_TAB_ITEMS,
|
||||||
type SettingsTab,
|
type SettingsSection,
|
||||||
|
type SettingsTabItem,
|
||||||
} from './settings-tabs-data'
|
} 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({
|
export function SettingsPageShell({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
tokenRequired,
|
tokenRequired,
|
||||||
|
showBird,
|
||||||
}: {
|
}: {
|
||||||
activeTab: SettingsTab
|
activeTab: SettingsSection
|
||||||
onTabChange: (tab: SettingsTab) => void
|
onTabChange: (tab: SettingsSection) => void
|
||||||
tokenRequired: boolean
|
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 (
|
return (
|
||||||
<BadgeTabs
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||||
value={activeTab}
|
<header className="px-1">
|
||||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1>
|
||||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
value: tab.value,
|
Интерфейс UI и глобальные параметры BIRD в одном разделе.
|
||||||
label: tab.label,
|
</p>
|
||||||
icon: tab.icon,
|
</header>
|
||||||
}))}
|
|
||||||
>
|
<Tabs
|
||||||
<TabsContent value="connection" className="mt-0">
|
value={resolvedTab}
|
||||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
onValueChange={(value) => onTabChange(value as SettingsSection)}
|
||||||
</TabsContent>
|
orientation={isMobile ? 'horizontal' : 'vertical'}
|
||||||
<TabsContent value="session" className="mt-0">
|
className="w-full gap-4 lg:gap-8"
|
||||||
<SessionSettingsTab />
|
>
|
||||||
</TabsContent>
|
<SettingsNavigation
|
||||||
<TabsContent value="appearance" className="mt-0">
|
isMobile={isMobile}
|
||||||
<AppearanceSettingsTab />
|
activeValue={resolvedTab}
|
||||||
</TabsContent>
|
items={items}
|
||||||
<TabsContent value="sections" className="mt-0">
|
/>
|
||||||
<SectionsSettingsTab />
|
|
||||||
</TabsContent>
|
<div className="min-w-0 flex-1">
|
||||||
</BadgeTabs>
|
<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 {
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
KeyRoundIcon,
|
|
||||||
MonitorSmartphoneIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
export type SettingsSection = 'ui' | 'bird'
|
||||||
|
|
||||||
export type SettingsTabItem = {
|
export type SettingsTabItem = {
|
||||||
value: SettingsTab
|
value: SettingsSection
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side rail items — settings-7 AccountSettings DNA. */
|
||||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
{
|
{
|
||||||
value: 'connection',
|
value: 'ui',
|
||||||
label: 'Подключение',
|
label: 'Настройки UI',
|
||||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
icon: <SettingsIcon aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'session',
|
value: 'bird',
|
||||||
label: 'Сессия',
|
label: 'Настройки BIRD',
|
||||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
icon: <ServerCogIcon aria-hidden="true" />,
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'appearance',
|
|
||||||
label: 'Оформление',
|
|
||||||
icon: <PaletteIcon aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'sections',
|
|
||||||
label: 'Разделы',
|
|
||||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
const LEGACY_BIRD = new Set(['bird', 'revision', 'runtime-logs', 'additional'])
|
||||||
if (
|
|
||||||
value === 'session' ||
|
/** Maps current and legacy `?tab=` values onto the unified settings rail. */
|
||||||
value === 'appearance' ||
|
export function parseSettingsSection(value: unknown): SettingsSection {
|
||||||
value === 'sections'
|
if (typeof value === 'string' && LEGACY_BIRD.has(value)) return 'bird'
|
||||||
) {
|
return 'ui'
|
||||||
return value
|
|
||||||
}
|
|
||||||
return 'connection'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,11 +346,17 @@ export function permissionForPath(pathname: string): string | null {
|
|||||||
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
if (pathname.startsWith('/tenant-settings') || pathname.startsWith('/settings')) {
|
||||||
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
return canOpenSettings() ? null : 'bgp:settings:read'
|
||||||
|
}
|
||||||
return null
|
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'
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
/** First path in the sidebar the current user may open. */
|
/** First path in the sidebar the current user may open. */
|
||||||
@@ -365,7 +371,6 @@ export function firstAllowedPath(): string {
|
|||||||
'/schedule',
|
'/schedule',
|
||||||
'/monitoring',
|
'/monitoring',
|
||||||
'/access',
|
'/access',
|
||||||
'/tenant-settings',
|
|
||||||
'/settings',
|
'/settings',
|
||||||
]
|
]
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: '/settings',
|
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 { createFileRoute } from '@tanstack/react-router'
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import { type SettingsTab } from '@/components/settings/settings-tabs-data'
|
import {
|
||||||
|
parseSettingsSection,
|
||||||
const settingsSearchSchema = z.object({
|
type SettingsSection,
|
||||||
tab: z
|
} from '@/components/settings/settings-tabs-data'
|
||||||
.enum(['connection', 'session', 'appearance', 'sections'])
|
import { can } from '@/lib/auth'
|
||||||
.catch('connection'),
|
|
||||||
reason: z.enum(['token-required']).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/settings')({
|
export const Route = createFileRoute('/_auth/_settings/settings')({
|
||||||
component: SettingsComponent,
|
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() {
|
function SettingsComponent() {
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const { tab, reason } = Route.useSearch()
|
const { tab, reason } = Route.useSearch()
|
||||||
const tokenRequired = reason === 'token-required'
|
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({
|
void navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tab: nextTab,
|
tab: nextTab,
|
||||||
|
reason: nextTab === 'ui' ? prev.reason : undefined,
|
||||||
}),
|
}),
|
||||||
replace: true,
|
replace: true,
|
||||||
})
|
})
|
||||||
@@ -33,9 +41,10 @@ function SettingsComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsPageShell
|
<SettingsPageShell
|
||||||
activeTab={tab}
|
activeTab={activeTab}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
tokenRequired={tokenRequired}
|
tokenRequired={tokenRequired}
|
||||||
|
showBird={showBird}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +1,8 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
export const Route = createFileRoute('/_auth/_settings/tenant-settings')({
|
||||||
component: TenantSettingsComponent,
|
beforeLoad: () => {
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
throw redirect({ to: '/settings', search: { tab: 'bird' } })
|
||||||
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
|
},
|
||||||
? search.tab
|
component: () => null,
|
||||||
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
search={{ tab: 'connection' }}
|
search={{ tab: 'ui' }}
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
настройках
|
настройках
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ function MonitoringComponent() {
|
|||||||
<IllustratedEmptyState
|
<IllustratedEmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Файловые логи"
|
title="Файловые логи"
|
||||||
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings."
|
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</BadgeTabs>
|
</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 здесь **не** редактируются. |
|
| `/settings?tab=ui` | Настройки UI: API-токен, сессия, тема. |
|
||||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
| `/settings?tab=bird` | Настройки BIRD: control plane, ревизии, файловые логи, дополнительные ключи `/v1/settings`. Старый `/tenant-settings` редиректит сюда. |
|
||||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
| `/network` → Control plane | Краткая сводка BIRD. |
|
||||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
| `/operations` | Ревизии, diff, jobs. |
|
||||||
|
|
||||||
### Web UI: файловые runtime-логи
|
### Web UI: файловые runtime-логи
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user