Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec 653bc6cc91 fix(web): update Russian translations for various components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 38s
CI / web (push) Successful in 56s
CI / release (push) Successful in 4m4s
Обновлены переводы на русский язык для компонентов, включая карточки мониторинга, сетевые панели и настройки. Исправлены описания и метки для улучшения пользовательского интерфейса, а также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage.
2026-08-18 17:00:15 +07:00
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
55 changed files with 747 additions and 748 deletions
@@ -27,10 +27,10 @@ export function MonitoringHealthCard({
return ( return (
<DonutBreakdownCard <DonutBreakdownCard
title="Доступность системы" title="Доступность системы"
description="GET /v1/health · GET /v1/ready" description="Проверки живучести и готовности"
slices={slices} slices={slices}
centerLabel="Проверки" centerLabel="Проверки"
badge={healthOk ? 'API OK' : undefined} badge={healthOk ? 'API в норме' : undefined}
/> />
) )
} }
@@ -40,15 +40,15 @@ export function NetworkOverviewAnalyticsCard({
/> />
<SegmentedProgressCard <SegmentedProgressCard
title="Спикеры" title="Спикеры"
description="Доступность live-агентов" description="Доступность агентов"
primary={{ primary={{
value: `${net.speakersOnline}/${net.speakersTotal}`, value: `${net.speakersOnline}/${net.speakersTotal}`,
label: 'Online', label: 'В сети',
percent: speakersPct, percent: speakersPct,
}} }}
secondary={{ secondary={{
value: net.speakersTotal - net.speakersOnline, value: net.speakersTotal - net.speakersOnline,
label: 'Offline', label: 'Не в сети',
percent: 100 - speakersPct, percent: 100 - speakersPct,
}} }}
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`} footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
@@ -100,7 +100,7 @@ function buildKpis({
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'} variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
size="sm" size="sm"
> >
{loading ? '…' : 'онлайн'} {loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
}, },
@@ -40,7 +40,7 @@ function buildMetrics({
{ {
id: 'bgp', id: 'bgp',
title: 'BGP готовность', title: 'BGP готовность',
label: 'Established / включённые', label: 'Установлено / включено',
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`, value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание', delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light', deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
@@ -62,13 +62,13 @@ function buildMetrics({
{ {
id: 'speakers', id: 'speakers',
title: 'Спикеры', title: 'Спикеры',
label: 'Online / всего', label: 'В сети / всего',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`, value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
delta: delta:
loading || network.speakersTotal === 0 loading || network.speakersTotal === 0
? '…' ? '…'
: network.speakersOnline === network.speakersTotal : network.speakersOnline === network.speakersTotal
? 'все online' ? 'все в сети'
: 'частично', : 'частично',
deltaVariant: deltaVariant:
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light', network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
{ {
id: 'lookup', id: 'lookup',
title: 'Проверка IP/домена', title: 'Проверка IP/домена',
description: 'Проверка IP в списках и BGP-сообществах.', description: 'Проверка IP в списках и BGP community.',
to: '/lookup', to: '/lookup',
icon: <Search aria-hidden />, icon: <Search aria-hidden />,
iconClassName: 'text-primary', iconClassName: 'text-primary',
@@ -22,8 +22,8 @@ const ACTIONS: QuickActionItem[] = [
}, },
{ {
id: 'communities', id: 'communities',
title: 'BGP-сообщества', title: 'BGP community',
description: 'Справочник BGP-сообществ для политик экспорта.', description: 'Справочник BGP community для политик экспорта.',
to: '/directories', to: '/directories',
icon: <Tags aria-hidden />, icon: <Tags aria-hidden />,
iconClassName: 'text-info', iconClassName: 'text-info',
@@ -71,7 +71,7 @@ export function CommunityFormDialog({
<FormDrawer <FormDrawer
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'} title={editTarget ? 'Редактировать community' : 'Новое BGP community'}
description="Тег для префиксов в фильтрах BIRD" description="Тег для префиксов в фильтрах BIRD"
className="sm:max-w-sm" className="sm:max-w-sm"
footer={ footer={
@@ -73,10 +73,10 @@ export function DirectoriesCommunitiesGrid({
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет сообществ" emptyMessage="Нет community"
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск сообществ…" searchPlaceholder="Поиск community…"
/> />
) )
} }
@@ -70,7 +70,7 @@ export function DohProfileFormDialog({
if (timeoutMs.trim() !== '') { if (timeoutMs.trim() !== '') {
const ms = Number(timeoutMs) const ms = Number(timeoutMs)
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) { if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
toast.error('Timeout должен быть целым числом > 0') toast.error('Таймаут должен быть целым числом больше 0')
return return
} }
timeout = ms timeout = ms
@@ -136,7 +136,7 @@ export function DohProfileFormDialog({
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label> <Label htmlFor="doh-timeout">Таймаут, мс (необязательно)</Label>
<Input <Input
id="doh-timeout" id="doh-timeout"
type="number" type="number"
+6 -8
View File
@@ -8,7 +8,6 @@ import {
Settings, Settings,
BookText, BookText,
KeyRound, KeyRound,
ServerCog,
Search, Search,
} from 'lucide-react' } from 'lucide-react'
@@ -72,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard', to: '/dashboard',
label: 'Панель', label: 'Панель',
icon: LayoutDashboard, icon: LayoutDashboard,
description: 'KPI, модули и активность', description: 'Метрики, модули и активность',
}, },
], ],
}, },
@@ -82,23 +81,22 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' }, { to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' }, { to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } }, { to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' }, { to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
], ],
}, },
{ {
label: 'Операции', label: 'Операции',
items: [ items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } }, { to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' }, { to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } }, { to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD', search: { tab: 'system' } },
], ],
}, },
{ {
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
@@ -94,8 +94,8 @@ export function SystemMonitorPopover() {
() => [ () => [
{ {
id: 'api', id: 'api',
label: 'API health', label: 'Состояние API',
value: healthOk ? 'OK' : '—', value: healthOk ? 'норма' : '—',
unit: '', unit: '',
percent: healthOk ? 100 : 0, percent: healthOk ? 100 : 0,
icon: <HeartPulse aria-hidden />, icon: <HeartPulse aria-hidden />,
@@ -14,8 +14,8 @@ import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api' import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [ const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' }, { value: 'plaintext', label: 'Текст' },
{ value: 'json', label: 'json' }, { value: 'json', label: 'JSON' },
] as const ] as const
interface ModuleCdnSourceDialogProps { interface ModuleCdnSourceDialogProps {
@@ -160,7 +160,7 @@ export function ModuleEditDialog({
<div className="grid min-w-0 flex-1 gap-1 pr-2"> <div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-enabled">Включён</Label> <Label htmlFor="mod-enabled">Включён</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в refresh и apply. Выключенный модуль не участвует в обновлении и применении.
</p> </p>
</div> </div>
<Checkbox <Checkbox
@@ -10,6 +10,7 @@ import { DataGridSection } 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 { formatDateTime } from '@/lib/modules/display' import { formatDateTime } from '@/lib/modules/display'
import { communityLabel } from '@/lib/modules/helpers' import { communityLabel } from '@/lib/modules/helpers'
import { cdnSourceKindRu } from '@/lib/ui-labels'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { import type {
AsEntry, AsEntry,
@@ -145,7 +146,7 @@ export function ModuleEntriesGrid({
accessorKey: 'source_kind', accessorKey: 'source_kind',
header: 'Тип', header: 'Тип',
cell: ({ row }: { row: { original: CdnSource } }) => ( cell: ({ row }: { row: { original: CdnSource } }) => (
<CategoryBadge>{row.original.source_kind}</CategoryBadge> <CategoryBadge>{cdnSourceKindRu(row.original.source_kind)}</CategoryBadge>
), ),
}, },
{ {
@@ -35,7 +35,7 @@ import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
function speakerLabel(s: SpeakerRow): string { function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') { if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)' return host ? `Плоскость · ${host}` : 'Плоскость управления'
} }
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}` return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
} }
@@ -51,7 +51,7 @@ const filterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-48', className: 'w-48',
placeholder: 'IP или Neighbor ID…', placeholder: 'IP или ID соседа…',
}, },
] ]
@@ -94,7 +94,7 @@ export function NetworkDiscoveredPeersCard({
id: 'neighbor_id', id: 'neighbor_id',
accessorFn: (row) => row.neighbor_id || row.neighbor, accessorFn: (row) => row.neighbor_id || row.neighbor,
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Neighbor ID" /> <DataGridColumnHeader column={column} title="ID соседа" />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridPrimaryCell <DataGridPrimaryCell
@@ -103,7 +103,7 @@ export function NetworkDiscoveredPeersCard({
accent="mono" accent="mono"
/> />
), ),
meta: { headerTitle: 'Neighbor ID' }, meta: { headerTitle: 'ID соседа' },
}, },
{ {
accessorKey: 'remote_asn', accessorKey: 'remote_asn',
@@ -212,7 +212,7 @@ export function NetworkDiscoveredPeersCard({
title="Одобрить пира" title="Одобрить пира"
description={ description={
approveTarget approveTarget
? `Neighbor ID ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}` ? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
: undefined : undefined
} }
className="sm:max-w-sm" className="sm:max-w-sm"
@@ -55,7 +55,7 @@ export function NetworkKpi({
} }
size="sm" size="sm"
> >
{loading ? '…' : 'онлайн'} {loading ? '…' : 'в сети'}
</Badge> </Badge>
), ),
to: '/network', to: '/network',
@@ -39,11 +39,11 @@ const PEER_TABS = [
const SESSION_STATE_OPTIONS = [ const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Установлена' }, { value: 'Established', label: 'Установлена' },
{ value: 'Idle', label: 'Idle' }, { value: 'Idle', label: 'Простой' },
{ value: 'Active', label: 'Active' }, { value: 'Active', label: 'Поиск' },
{ value: 'Connect', label: 'Connect' }, { value: 'Connect', label: 'Соединение' },
{ value: 'OpenSent', label: 'OpenSent' }, { value: 'OpenSent', label: 'Open отправлен' },
{ value: 'OpenConfirm', label: 'OpenConfirm' }, { value: 'OpenConfirm', label: 'Open подтверждён' },
] ]
function createDefaultPeerFilters(): Filter[] { function createDefaultPeerFilters(): Filter[] {
@@ -31,14 +31,14 @@ function speakerDeleteLabel(s: SpeakerRow): string {
const SPEAKER_TABS = [ const SPEAKER_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' }, { id: 'online', label: 'В сети' },
{ id: 'offline', label: 'Offline' }, { id: 'offline', label: 'Не в сети' },
] ]
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' }, { value: 'primary', label: 'Основной' },
{ value: 'secondary', label: 'secondary' }, { value: 'secondary', label: 'Резервный' },
{ value: 'speaker', label: 'speaker' }, { value: 'speaker', label: 'Спикер' },
] ]
function createDefaultSpeakerFilters(): Filter[] { function createDefaultSpeakerFilters(): Filter[] {
@@ -52,7 +52,7 @@ const speakerFilterFields: FilterFieldConfig[] = [
icon: <SearchIcon className="size-3.5" aria-hidden />, icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text', type: 'text',
className: 'w-52', className: 'w-52',
placeholder: 'endpoint…', placeholder: 'Адрес агента…',
}, },
{ {
key: 'role', key: 'role',
@@ -128,7 +128,7 @@ export function NetworkSpeakersCard({
<> <>
<ResourcePage <ResourcePage
title="Спикеры" title="Спикеры"
description="BIRD-агенты на нодах tenant" description="BIRD-агенты на нодах арендатора"
tabs={SPEAKER_TABS} tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter} tabFilter={speakerTabFilter}
filterFields={speakerFilterFields} filterFields={speakerFilterFields}
@@ -5,7 +5,7 @@ import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { speakerOnlineLabel } from '@/lib/ui-labels' import { speakerOnlineLabel, speakerRoleRu } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api' import type { SpeakerRow } from '@/types/api'
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
@@ -20,7 +20,7 @@ export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{ {
accessorKey: 'role', accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>, cell: ({ row }) => <CategoryBadge>{speakerRoleRu(row.original.role)}</CategoryBadge>,
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{ {
@@ -22,7 +22,7 @@ interface PeerFormDialogProps {
function speakerLabel(s: SpeakerRow): string { function speakerLabel(s: SpeakerRow): string {
if (s.role === 'master') { if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint const host = s.agent_domain ?? s.endpoint
return host ? `CP · ${host}` : 'CP (master)' return host ? `Плоскость · ${host}` : 'Плоскость управления'
} }
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}` return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}`
} }
@@ -74,7 +74,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
const ep = const ep =
endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '') endpoint.trim() || (agentDomain.trim() ? `https://${agentDomain.trim()}` : '')
if (!ep) { if (!ep) {
toast.error('Укажите endpoint или agent domain') toast.error('Укажите конечную точку или домен агента')
return return
} }
const body: BgpSpeakerCreate = { const body: BgpSpeakerCreate = {
@@ -95,7 +95,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
title="Новый спикер" title="Новый спикер"
description="BIRD-агент на ноде реплики или control plane" description="BIRD-агент на ноде реплики или плоскости управления"
className="sm:max-w-md" className="sm:max-w-md"
footer={ footer={
<> <>
@@ -122,7 +122,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
label="Роль" label="Роль"
items={[ items={[
{ value: 'replica', label: 'Реплика' }, { value: 'replica', label: 'Реплика' },
{ value: 'master', label: 'Мастер (CP)' }, { value: 'master', label: 'Мастер (плоскость)' },
]} ]}
value={role} value={role}
onValueChange={(v) => setRole(v ?? 'replica')} onValueChange={(v) => setRole(v ?? 'replica')}
@@ -146,7 +146,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="speaker-bgp-source">BGP source IPv4</Label> <Label htmlFor="speaker-bgp-source">Исходный IPv4 BGP</Label>
<Input <Input
id="speaker-bgp-source" id="speaker-bgp-source"
placeholder="203.0.113.10" placeholder="203.0.113.10"
@@ -77,7 +77,7 @@ export function OperationsRevisionsGrid({
</Button> </Button>
} }
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`} title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator." description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
confirmLabel="Откатить" confirmLabel="Откатить"
destructive destructive
onConfirm={() => rollbackMutation.mutate(row.original.id)} onConfirm={() => rollbackMutation.mutate(row.original.id)}
+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,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>
) )
} }
@@ -61,7 +61,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="hidden space-x-1 lg:flex"> <div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? ( {selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal"> <Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected {selectedValues.size} выбрано
</Badge> </Badge>
) : ( ) : (
options options
@@ -94,7 +94,7 @@ function DataGridColumnFilter<TData, TValue>({
<div className="max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm"> <div className="text-muted-foreground py-6 text-center text-sm">
No results found. Ничего не найдено.
</div> </div>
) : ( ) : (
<div className="p-1"> <div className="p-1">
@@ -170,7 +170,7 @@ function DataGridColumnFilter<TData, TValue>({
}} }}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none" className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
> >
Clear filters Сбросить фильтры
</div> </div>
</div> </div>
</> </>
@@ -39,11 +39,11 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
sizes: [5, 10, 25, 50, 100], sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />, sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5, moreLimit: 5,
info: "{from} - {to} of {count}", info: "{from}{to} из {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />, infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page", rowsPerPageLabel: "Строк на странице",
previousPageLabel: "Go to previous page", previousPageLabel: "Предыдущая страница",
nextPageLabel: "Go to next page", nextPageLabel: "Следующая страница",
ellipsisText: "...", ellipsisText: "...",
} }
@@ -73,7 +73,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className className
)} )}
aria-label="Drag to reorder row" aria-label="Перетащить строку"
disabled disabled
> >
<GripHorizontalIcon aria-hidden="true" /> <GripHorizontalIcon aria-hidden="true" />
@@ -89,7 +89,7 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className className
)} )}
aria-label="Drag to reorder row" aria-label="Перетащить строку"
{...context.attributes} {...context.attributes}
{...context.listeners} {...context.listeners}
> >
@@ -105,7 +105,7 @@ function DataGridTableDndHeader<TData>({
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`} className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes} {...attributes}
{...listeners} {...listeners}
aria-label="Drag to reorder" aria-label="Перетащить для изменения порядка"
> >
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" /> <GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button> </Button>
@@ -397,9 +397,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage = const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..." props.fetchingMoreMessage || props.loadingMessage || "Загрузка…"
const allRowsLoadedMessage = const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded" props.allRowsLoadedMessage || "Все записи загружены"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => { const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({ setViewportElements({
@@ -1241,7 +1241,7 @@ function DataGridTableEmpty() {
colSpan={Math.max(visibleColumnCount, 1)} colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground py-6 text-center text-sm" className="text-muted-foreground py-6 text-center text-sm"
> >
{props.emptyMessage || "No data available"} {props.emptyMessage || "Нет данных"}
</td> </td>
</tr> </tr>
) )
@@ -1254,7 +1254,7 @@ function DataGridTableLoader() {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium"> <div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
<Spinner className="size-5 opacity-60" /> <Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."} {props.loadingMessage || "Загрузка…"}
</div> </div>
</div> </div>
) )
@@ -1266,7 +1266,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return ( return (
<button <button
type="button" type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"} aria-label={isPinned ? "Открепить строку" : "Закрепить строку"}
onClick={() => { onClick={() => {
if (isPinned) { if (isPinned) {
row.pin(false) row.pin(false)
@@ -1322,7 +1322,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row" aria-label="Выбрать строку"
className="align-[inherit]" className="align-[inherit]"
/> />
</> </>
@@ -1341,7 +1341,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected} indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0} disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Выбрать все"
className="align-[inherit]" className="align-[inherit]"
/> />
) )
@@ -1408,7 +1408,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
{props.loadingMessage || "Loading..."} {props.loadingMessage || "Загрузка…"}
</div> </div>
</td> </td>
</tr> </tr>
+49 -54
View File
@@ -123,75 +123,70 @@ export interface FilterI18nConfig {
// Default English i18n configuration // Default English i18n configuration
export const DEFAULT_I18N: FilterI18nConfig = { export const DEFAULT_I18N: FilterI18nConfig = {
// UI Labels addFilter: "Фильтр",
addFilter: "Filter", searchFields: "Фильтр…",
searchFields: "Filter...", noFieldsFound: "Поля не найдены.",
noFieldsFound: "No filters found.", noResultsFound: "Ничего не найдено.",
noResultsFound: "No results found.", select: "Выбрать…",
select: "Select...", true: "Да",
true: "True", false: "Нет",
false: "False", min: "Мин.",
min: "Min", max: "Макс.",
max: "Max", to: "",
to: "to", typeAndPressEnter: "Введите и нажмите Enter",
typeAndPressEnter: "Type and press Enter to add tag", selected: "выбрано",
selected: "selected", selectedCount: "выбрано",
selectedCount: "selected",
percent: "%", percent: "%",
defaultCurrency: "$", defaultCurrency: "$",
defaultColor: "#000000", defaultColor: "#000000",
addFilterTitle: "Add filter", addFilterTitle: "Добавить фильтр",
// Operators
operators: { operators: {
is: "is", is: "равно",
isNot: "is not", isNot: "не равно",
isAnyOf: "is any of", isAnyOf: "любое из",
isNotAnyOf: "is not any of", isNotAnyOf: "кроме",
includesAll: "includes all", includesAll: "включает все",
excludesAll: "excludes all", excludesAll: "исключает все",
before: "before", before: "до",
after: "after", after: "после",
between: "between", between: "между",
notBetween: "not between", notBetween: "вне диапазона",
contains: "contains", contains: "содержит",
notContains: "does not contain", notContains: "не содержит",
startsWith: "starts with", startsWith: "начинается с",
endsWith: "ends with", endsWith: "заканчивается на",
isExactly: "is exactly", isExactly: "точно",
equals: "equals", equals: "равно",
notEquals: "not equals", notEquals: "не равно",
greaterThan: "greater than", greaterThan: "больше",
lessThan: "less than", lessThan: "меньше",
overlaps: "overlaps", overlaps: "пересекается",
includes: "includes", includes: "включает",
excludes: "excludes", excludes: "исключает",
includesAllOf: "includes all of", includesAllOf: "включает все",
includesAnyOf: "includes any of", includesAnyOf: "включает любое",
empty: "is empty", empty: "пусто",
notEmpty: "is not empty", notEmpty: "не пусто",
}, },
// Placeholders
placeholders: { placeholders: {
enterField: (fieldType: string) => `Enter ${fieldType}...`, enterField: (fieldType: string) => `Введите ${fieldType}`,
selectField: "Select...", selectField: "Выбрать…",
searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}`,
enterKey: "Enter key...", enterKey: "Введите ключ…",
enterValue: "Enter value...", enterValue: "Введите значение…",
}, },
// Helper functions
helpers: { helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "), formatOperator: (operator: string) => operator.replace(/_/g, " "),
}, },
// Validation
validation: { validation: {
invalidEmail: "Invalid email format", invalidEmail: "Некорректный email",
invalidUrl: "Invalid URL format", invalidUrl: "Некорректный URL",
invalidTel: "Invalid phone format", invalidTel: "Некорректный телефон",
invalid: "Invalid input format", invalid: "Некорректное значение",
}, },
} }
@@ -107,7 +107,7 @@ export function ScheduleAgendaPanel({
return ( return (
<PanelCard <PanelCard
title="Календарь задач" title="Календарь задач"
description="Задачи refresh и apply по дням" description="Задачи обновления и применения по дням"
contentClassName={cn(panelCardContentFlushClassName, 'p-0')} contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
> >
<div className="flex flex-col lg:flex-row"> <div className="flex flex-col lg:flex-row">
@@ -43,7 +43,7 @@ export function ScheduleModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
{row.original.cron_expr ?? {row.original.cron_expr ??
(row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec}s` : '—')} (row.original.refresh_interval_sec ? `${row.original.refresh_interval_sec} с` : '—')}
</span> </span>
), ),
meta: { headerTitle: 'Расписание' }, meta: { headerTitle: 'Расписание' },
@@ -90,16 +90,16 @@ export function AppearanceSettingsTab() {
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title="Дашборд" title="Обзор"
description="Блоки на экране «Обзор»" description="Блоки на экране «Обзор»"
> >
<SettingsFieldGroup <SettingsFieldGroup
legend="Быстрые действия" legend="Быстрые действия"
description="Показывать KPI-like плитки быстрых переходов под метриками." description="Показывать плитки быстрых переходов под метриками."
> >
<SettingRow <SettingRow
title="Быстрые действия" title="Быстрые действия"
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде." description="Блок с частыми переходами (модули, сеть, деплой) на экране «Обзор»."
last last
> >
<Switch <Switch
@@ -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: 'Обнулить файл' },
{ value: 'delete', label: 'Удалить файл' },
] 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: 'Исходный IPv4 BGP',
bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
peer_discovery_require_external: 'Только внешние ASN',
}
const BIRD_BOOL_ITEMS = [
{ value: 'true', label: 'Вкл' },
{ value: 'false', label: 'Выкл' },
] as const
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled:
'Динамический диапазон соседей в 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: 'Диапазон соседей с внешним (любым чужим) 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"
description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
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="Хранение"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Срок хранения"
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>
)
}
@@ -63,7 +63,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
<SettingsCard <SettingsCard
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)" description="Токен хранится только в этом браузере"
footer={ footer={
<div className="flex w-full min-w-0 flex-wrap justify-end gap-2"> <div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}> <Button type="button" variant="outline" onClick={useDevToken}>
@@ -84,7 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов" title="Токен для запросов"
description={ description={
<> <>
Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '} Ключ для заголовка Authorization. Управление ключами арендатора в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline"> <Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа Права доступа
</Link> </Link>
@@ -93,7 +93,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
} }
titleAddon={ titleAddon={
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
localStorage браузер
</Badge> </Badge>
} }
labelFor="settings-token" labelFor="settings-token"
@@ -23,27 +23,30 @@ 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" />,
badge: { label: 'operator', variant: 'warning-light' as const }, badge: { label: 'оператор', variant: 'warning-light' as const },
}, },
{ {
id: 'access', id: 'access',
to: '/access' as const, to: '/access' as const,
search: undefined,
title: 'Права доступа', title: 'Права доступа',
description: 'API-ключи tenant, роли и управление доступом.', description: 'API-ключи арендатора, роли и управление доступом.',
icon: <KeyRoundIcon aria-hidden="true" />, icon: <KeyRoundIcon aria-hidden="true" />,
badge: { label: 'operator', variant: 'warning-light' as const }, badge: { label: 'оператор', variant: 'warning-light' as const },
}, },
{ {
id: 'monitoring', id: 'monitoring',
to: '/monitoring' as const, to: '/monitoring' as const,
search: undefined,
title: 'Мониторинг', title: 'Мониторинг',
description: 'Метрики, состояние jobs и observability control plane.', description: 'Метрики, состояние задач и наблюдаемость плоскости управления.',
icon: <ActivityIcon aria-hidden="true" />, icon: <ActivityIcon aria-hidden="true" />,
badge: { label: 'viewer+', variant: 'info-light' as const }, badge: { label: 'просмотр+', variant: 'info-light' as const },
}, },
] as const ] as const
@@ -51,8 +54,8 @@ export function SectionsSettingsTab() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Разделы control plane" title="Разделы плоскости управления"
description="Параметры tenant и операции — отдельно от настроек браузера" description="Параметры арендатора и операции — отдельно от настроек браузера"
> >
<ItemGroup className="gap-0"> <ItemGroup className="gap-0">
{ADMIN_SECTIONS.map((section, index) => ( {ADMIN_SECTIONS.map((section, index) => (
@@ -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>
@@ -36,7 +36,7 @@ export function SessionSettingsTab() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session"> <SettingsCard title="Текущая сессия" description="Проверка токена через API сессии">
<QueryState <QueryState
data={sessionQ.data} data={sessionQ.data}
isLoading={sessionQ.isLoading && hasStoredToken} isLoading={sessionQ.isLoading && hasStoredToken}
@@ -65,7 +65,7 @@ export function SessionSettingsTab() {
</Badge> </Badge>
</ItemTitle> </ItemTitle>
<ItemDescription className="leading-5"> <ItemDescription className="leading-5">
Tenant:{' '} Арендатор:{' '}
<code className="text-foreground font-mono text-xs break-all"> <code className="text-foreground font-mono text-xs break-all">
{session.tenant_id} {session.tenant_id}
</code> </code>
@@ -84,7 +84,7 @@ export function SessionSettingsTab() {
> >
<SettingRow <SettingRow
title="Роль" title="Роль"
description="Определяет доступ к операциям control plane и CRUD." description="Определяет доступ к операциям плоскости управления и изменению данных."
titleAddon={ titleAddon={
sessionQ.data ? ( sessionQ.data ? (
<Badge variant="info-light" size="sm"> <Badge variant="info-light" size="sm">
@@ -101,7 +101,7 @@ export function SessionSettingsTab() {
> >
{sessionQ.data ? ( {sessionQ.data ? (
<p className="text-muted-foreground text-sm break-words"> <p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '} Ключ с ролью <strong>{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}</strong> у арендатора{' '}
<code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>. <code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p> </p>
) : ( ) : (
@@ -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'
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { apiKeyRoleRu } from '@/lib/ui-labels'
export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [ export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` }, { value: 'viewer', label: `${apiKeyRoleRu('viewer')} — только чтение` },
{ value: 'editor', label: `${apiKeyRoleRu('editor')}CRUD без применения` }, { value: 'editor', label: `${apiKeyRoleRu('editor')}изменение данных без применения` },
{ value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` }, { value: 'operator', label: `${apiKeyRoleRu('operator')} — полный доступ` },
{ value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` }, { value: 'node', label: `${apiKeyRoleRu('node')} — только API ноды` },
] ]
+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) {
@@ -38,7 +38,7 @@ describe('isReadyCheckOk', () => {
describe('readyCheckStatusLabel', () => { describe('readyCheckStatusLabel', () => {
it('labels memory and failures', () => { it('labels memory and failures', () => {
expect(readyCheckStatusLabel('memory', true)).toBe('Memory') expect(readyCheckStatusLabel('memory', true)).toBe('В памяти')
expect(readyCheckStatusLabel('ok', true)).toBe('В норме') expect(readyCheckStatusLabel('ok', true)).toBe('В норме')
expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно') expect(readyCheckStatusLabel('unavailable', false)).toBe('Недоступно')
}) })
@@ -54,7 +54,7 @@ export function readyCheckStatusLabel(value: ReadyCheckValue, ok: boolean): stri
} }
if (typeof value === 'string') { if (typeof value === 'string') {
const n = value.trim().toLowerCase() const n = value.trim().toLowerCase()
if (n === 'memory') return 'Memory' if (n === 'memory') return 'В памяти'
if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме' if (n === 'ok' || n === 'ready' || n === 'healthy' || n === 'true') return 'В норме'
if (n) return value if (n) return value
} }
+31 -4
View File
@@ -61,18 +61,45 @@ const JOB_STATUS_RU: Record<string, string> = {
paused: 'Приостановлен', paused: 'Приостановлен',
disabled: 'Выключен', disabled: 'Выключен',
archived: 'В архиве', archived: 'В архиве',
block: 'block', block: 'Блокировать',
accept: 'accept', accept: 'Принимать',
} }
export function jobStatusRu(status: string): string { export function jobStatusRu(status: string): string {
return JOB_STATUS_RU[status.toLowerCase()] ?? status return JOB_STATUS_RU[status.toLowerCase()] ?? status
} }
const BGP_SESSION_STATE_RU: Record<string, string> = {
Idle: 'Простой',
Connect: 'Соединение',
Active: 'Поиск',
OpenSent: 'Open отправлен',
OpenConfirm: 'Open подтверждён',
Established: 'Установлена',
}
export function bgpSessionStateRu(state: string | null | undefined): string { export function bgpSessionStateRu(state: string | null | undefined): string {
if (!state) return '—' if (!state) return '—'
if (state === 'Established') return 'Установлена' return BGP_SESSION_STATE_RU[state] ?? state
return state }
export function speakerRoleRu(role: string | null | undefined): string {
switch (role) {
case 'master':
return 'Основной'
case 'primary':
return 'Основной'
case 'secondary':
return 'Резервный'
case 'speaker':
return 'Спикер'
default:
return role?.trim() || '—'
}
}
export function cdnSourceKindRu(kind: string): string {
return kind === 'json' ? 'JSON' : 'Текст'
} }
export function speakerOnlineLabel(agentOk: boolean | undefined): string { export function speakerOnlineLabel(agentOk: boolean | undefined): string {
+4 -4
View File
@@ -41,11 +41,11 @@ export function useCreateCommunityMutation() {
mutationFn: (body: BgpCommunityCreate) => mutationFn: (body: BgpCommunityCreate) =>
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }), apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
onSuccess: () => { onSuccess: () => {
toast.success('Сообщество создано') toast.success('Community создано')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
}, },
onError: (e) => onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'), toast.error(e instanceof Error ? e.message : 'Не удалось создать community'),
}) })
} }
@@ -55,11 +55,11 @@ export function useUpdateCommunityMutation() {
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) => mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }), apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', body, { idempotent: false }),
onSuccess: () => { onSuccess: () => {
toast.success('Сообщество обновлено') toast.success('Community обновлено')
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
}, },
onError: (e) => onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось обновить сообщество'), toast.error(e instanceof Error ? e.message : 'Не удалось обновить community'),
}) })
} }
+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>
)
}
+10 -10
View File
@@ -52,7 +52,7 @@ function AccessComponent() {
icon: <KeyRound className="size-4" />, icon: <KeyRound className="size-4" />,
footer: ( footer: (
<Badge variant="primary-light" size="sm"> <Badge variant="primary-light" size="sm">
в tenant в арендаторе
</Badge> </Badge>
), ),
}, },
@@ -89,16 +89,16 @@ function AccessComponent() {
} }
const sessionKindLabel = const sessionKindLabel =
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
const sessionAccessLabel = (() => { const sessionAccessLabel = (() => {
if (!session) return null if (!session) return null
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) { if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
if (session.is_admin) return 'admin (portal)' if (session.is_admin) return 'Администратор (портал)'
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin' if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
return session.permissions?.length return session.permissions?.length
? session.permissions.slice(0, 3).join(', ') ? session.permissions.slice(0, 3).join(', ')
: 'без access:admin' : 'без права access:admin'
} }
return session.role || '—' return session.role || '—'
})() })()
@@ -107,7 +107,7 @@ function AccessComponent() {
<div className="flex min-w-0 flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<PageHeader <PageHeader
title="Права доступа" title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена." description="API-ключи плоскости управления и текущая сессия."
actions={ actions={
canManageKeys ? ( canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}> <Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
@@ -121,7 +121,7 @@ function AccessComponent() {
{session ? ( {session ? (
<SettingsCard <SettingsCard
title="Текущая сессия" title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)." description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
> >
<ItemGroup className="gap-0"> <ItemGroup className="gap-0">
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5"> <Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
@@ -141,7 +141,7 @@ function AccessComponent() {
</div> </div>
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5"> <ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<span className="break-all"> <span className="break-all">
Tenant:{' '} Арендатор:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code> <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</span> </span>
<span className="break-words">Доступ: {sessionAccessLabel}</span> <span className="break-words">Доступ: {sessionAccessLabel}</span>
@@ -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"
> >
настройках настройках
@@ -188,8 +188,8 @@ function AccessComponent() {
) : session ? ( ) : session ? (
<SettingsCard title="API-ключи"> <SettingsCard title="API-ключи">
<p className="text-muted-foreground px-5 py-4 text-sm break-words"> <p className="text-muted-foreground px-5 py-4 text-sm break-words">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>. с правом администратора / <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>. Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p> </p>
</SettingsCard> </SettingsCard>
+6 -6
View File
@@ -45,7 +45,7 @@ function DirectoriesComponent() {
const items: KpiStatItem[] = [ const items: KpiStatItem[] = [
{ {
label: 'Сообщества BGP', label: 'BGP community',
value: communities.length, value: communities.length,
icon: <Tags className="size-4" />, icon: <Tags className="size-4" />,
footer: ( footer: (
@@ -70,7 +70,7 @@ function DirectoriesComponent() {
icon: <BookText className="size-4" />, icon: <BookText className="size-4" />,
footer: ( footer: (
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm">
все модули tenant все модули арендатора
</Badge> </Badge>
), ),
}, },
@@ -114,7 +114,7 @@ function DirectoriesComponent() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<PageHeader <PageHeader
title="Справочники" title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов" description="BGP community и DoH-профили для резолвинга доменов"
actions={ actions={
<Button <Button
variant="outline" variant="outline"
@@ -136,13 +136,13 @@ function DirectoriesComponent() {
<BadgeTabs <BadgeTabs
defaultValue="communities" defaultValue="communities"
items={[ items={[
{ value: 'communities', label: 'Сообщества BGP', count: communities.length }, { value: 'communities', label: 'BGP community', count: communities.length },
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' }, { value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
]} ]}
> >
<TabsContent value="communities" className="mt-0"> <TabsContent value="communities" className="mt-0">
<FrameDataGrid <FrameDataGrid
title="Сообщества BGP" title="BGP community"
description="Теги для префиксов в фильтрах BIRD" description="Теги для префиксов в фильтрах BIRD"
actions={addCommunityButton} actions={addCommunityButton}
> >
@@ -152,7 +152,7 @@ function DirectoriesComponent() {
isError={communitiesQ.isError} isError={communitiesQ.isError}
error={communitiesQ.error} error={communitiesQ.error}
empty={communities.length === 0} empty={communities.length === 0}
emptyTitle="Нет сообществ" emptyTitle="Нет community"
emptyAction={addCommunityButton} emptyAction={addCommunityButton}
skeleton={<TableSkeleton rows={4} cols={3} />} skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()} onRetry={() => communitiesQ.refetch()}
+7 -8
View File
@@ -127,7 +127,7 @@ function MonitoringComponent() {
<div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2"> <div className="grid items-stretch gap-2 md:gap-3 lg:grid-cols-2">
<FrameDataGrid <FrameDataGrid
title="Доступность и готовность" title="Доступность и готовность"
description="GET /v1/health · GET /v1/ready" description="Проверки живучести и готовности (/v1/health, /v1/ready)"
> >
<QueryState <QueryState
data={readyQ.data} data={readyQ.data}
@@ -148,7 +148,7 @@ function MonitoringComponent() {
BGP на API-хосте BGP на API-хосте
</span> </span>
} }
description="GET /v1/bird/status" description="Статус BIRD на хосте API"
className="h-full" className="h-full"
contentClassName="px-5 py-4" contentClassName="px-5 py-4"
> >
@@ -169,7 +169,7 @@ function MonitoringComponent() {
<div className="flex flex-col gap-2 md:gap-3"> <div className="flex flex-col gap-2 md:gap-3">
<SegmentedProgressCard <SegmentedProgressCard
title="Задачи" title="Задачи"
description="Последние 100 задач · GET /v1/jobs" description="Последние 100 задач"
primary={{ primary={{
value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length, value: jobs.filter((j) => j.status === 'running' || j.status === 'queued').length,
label: 'Активных', label: 'Активных',
@@ -230,11 +230,10 @@ function MonitoringComponent() {
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '} <span className="font-medium text-foreground">Готовность не «Готов».</span> Сначала{' '}
<code className="text-xs">postgres</code>, затем <code className="text-xs">store</code>{' '} <code className="text-xs">PostgreSQL</code>, затем хранилище и очередь задач в проверках.
и <code className="text-xs">jobs</code> в проверках.
</li> </li>
<li> <li>
<span className="font-medium text-foreground">Низкий ratio BGP.</span> Проверьте{' '} <span className="font-medium text-foreground">Низкая доля установленных BGP-сессий.</span> Проверьте{' '}
<code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети. <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</li> </li>
<li> <li>
@@ -250,7 +249,7 @@ function MonitoringComponent() {
<IllustratedEmptyState <IllustratedEmptyState
icon={Database} icon={Database}
title="PostgreSQL" title="PostgreSQL"
description="Статус соединения и пул отображаются в readiness-проверке на вкладке «Система» (check postgres)." description="Состояние соединения и пула видно в проверке готовности на вкладке «Система»."
/> />
</TabsContent> </TabsContent>
@@ -258,7 +257,7 @@ function MonitoringComponent() {
<IllustratedEmptyState <IllustratedEmptyState
icon={FileText} icon={FileText}
title="Файловые логи" title="Файловые логи"
description="Логи API и pipeline настраиваются переменной EVOBGP_LOG_* и управляются в tenant-settings." description="Логи API и конвейера задаются переменной 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-логи