Compare commits

..
2 Commits
Author SHA1 Message Date
Denozordec e0d695f2a4 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
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.
2026-08-18 16:30:10 +07:00
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
25 changed files with 601 additions and 661 deletions
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import { import {
ChartContainer, ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig, type ChartConfig,
} from '@evobgp/ui/components/chart' } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}> <div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0"> <ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart> <PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie <Pie
data={data} data={data}
dataKey="count" dataKey="count"
+1 -1
View File
@@ -46,7 +46,7 @@ export function BadgeTabs({
<TabsList <TabsList
variant="line" variant="line"
className={cn( className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4 overflow-x-auto', 'mb-3.5 w-full min-w-0 justify-start gap-4',
listClassName, listClassName,
)} )}
> >
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card' import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics' import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api' import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules' type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */ /**
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
* @see https://reui.io/preview/base/chart-27
* @see https://reui.io/docs/components/base/frame
*/
export function DashboardOperationsBreakdown({ export function DashboardOperationsBreakdown({
jobs, jobs,
modules, modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return ( return (
<PanelCard <PanelCard
title="Поток операций" title="Поток операций"
description="Распределение задач и типов модулей" description="Доли статусов задач или типов модулей"
actions={ actions={
<AnalyticsSegmentControl <AnalyticsSegmentControl
value={mode} value={mode}
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
]} ]}
/> />
} }
className="h-full" className="min-w-0"
contentClassName="px-5 py-4"
> >
<div className="p-4"> {loading ? (
{loading ? ( <div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm"> Загрузка
Загрузка </div>
</div> ) : (
) : ( <ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
<> )}
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
</PanelCard> </PanelCard>
) )
} }
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
[nameById], [nameById],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => { getSearchText: (row) => {
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => row.id, getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" emptyMessage="Нет ревизий"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
+1 -3
View File
@@ -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' } },
], ],
}, },
] ]
+1 -1
View File
@@ -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"
> >
Настройки Настройки
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
} }
/** /**
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue. * Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
* @see https://reui.io/preview/base/dashboard-1 * @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/ */
export function OpsDashboard({ export function OpsDashboard({
title = 'Панель управления', title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null} {afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section <section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
aria-label="Аналитика"
className="flex min-w-0 flex-col gap-4"
>
{charts} {charts}
</section> </section>
<section aria-label={queueTitle}> <section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<Frame dense spacing="sm" className="w-full min-w-0"> <div className="flex min-w-0 flex-col gap-1">
<FrameHeader> <h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
<FrameTitle>{queueTitle}</FrameTitle> {queueDescription ? (
<FrameDescription>{queueDescription}</FrameDescription> <p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
</FrameHeader> ) : null}
<FramePanel>{queue}</FramePanel> </div>
</Frame> {queue}
</section> </section>
</div> </div>
) )
@@ -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'
} }
+8 -3
View File
@@ -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) {
+1 -1
View File
@@ -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>
)
}
+1 -1
View File
@@ -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"
> >
настройках настройках
+7 -10
View File
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline' import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid' import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid' import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health' import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links' import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { OpsDashboard } from '@/components/reui-kit' import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface' import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
import { import {
moduleNameById, moduleNameById,
@@ -111,7 +110,7 @@ function DashboardComponent() {
peers={peers} peers={peers}
speakers={speakers} speakers={speakers}
/> />
<div className="grid min-w-0 gap-4"> <div className="grid min-w-0 items-start gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} /> <DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} /> <DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div> </div>
@@ -122,28 +121,26 @@ function DashboardComponent() {
queueDescription="Последние фоновые операции и история конфигураций" queueDescription="Последние фоновые операции и история конфигураций"
queue={ queue={
<div className={chartPanelGridClassName}> <div className={chartPanelGridClassName}>
<DashboardFramePanel <FrameDataGrid
title="Недавние задачи" title="Недавние задачи"
description="Последние фоновые операции" description="Последние фоновые операции"
className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} /> <DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
<DashboardFramePanel <FrameDataGrid
title="Последние ревизии" title="Последние ревизии"
description="История конфигураций" description="История конфигураций"
className="h-full min-w-0"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} /> <DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
</div> </div>
} }
/> />
+1 -1
View File
@@ -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
View File
@@ -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-логи
+10 -7
View File
@@ -24,12 +24,13 @@ function Tabs({
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-muted", default:
line: "gap-1 border-b border-border bg-transparent", "bg-muted p-[3px] group-data-[orientation=horizontal]/tabs:h-9",
line: "h-auto gap-1 overflow-visible border-b border-border bg-transparent p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:py-1.5 group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
"data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-0 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className className
)} )}
{...props} {...props}