Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d695f2a4 |
@@ -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