Compare commits

...
1 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
43 changed files with 214 additions and 192 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"
+5 -5
View File
@@ -71,7 +71,7 @@ const NAV_GROUPS: NavGroup[] = [
to: '/dashboard', to: '/dashboard',
label: 'Панель', label: 'Панель',
icon: LayoutDashboard, icon: LayoutDashboard,
description: 'KPI, модули и активность', description: 'Метрики, модули и активность',
}, },
], ],
}, },
@@ -81,15 +81,15 @@ 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' } },
], ],
}, },
{ {
@@ -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)}
@@ -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
@@ -31,8 +31,8 @@ const RUNTIME_LOGS_ENABLED_ITEMS = [
] as const ] as const
const RUNTIME_LOGS_MODE_ITEMS = [ const RUNTIME_LOGS_MODE_ITEMS = [
{ value: 'truncate', label: 'обнулить (truncate)' }, { value: 'truncate', label: 'Обнулить файл' },
{ value: 'delete', label: 'удалить файл (delete)' }, { value: 'delete', label: 'Удалить файл' },
] as const ] as const
const BIRD_LABELS: Record<BirdSettingKey, string> = { const BIRD_LABELS: Record<BirdSettingKey, string> = {
@@ -40,12 +40,12 @@ const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_local_ipv4: 'Локальный IPv4', bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6', bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN', bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4', bird_bgp_source_ipv4: 'Исходный IPv4 BGP',
bird_bgp_source_ipv6: 'BGP source IPv6', bird_bgp_source_ipv6: 'Исходный IPv6 BGP',
peer_discovery_enabled: 'Автообнаружение пиров', peer_discovery_enabled: 'Автообнаружение пиров',
peer_discovery_ranges_v4: 'Discovery CIDR IPv4', peer_discovery_ranges_v4: 'CIDR автообнаружения IPv4',
peer_discovery_ranges_v6: 'Discovery CIDR IPv6', peer_discovery_ranges_v6: 'CIDR автообнаружения IPv6',
peer_discovery_require_external: 'Только external ASN', peer_discovery_require_external: 'Только внешние ASN',
} }
const BIRD_BOOL_ITEMS = [ const BIRD_BOOL_ITEMS = [
@@ -55,10 +55,10 @@ const BIRD_BOOL_ITEMS = [
const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = { const BIRD_HINTS: Partial<Record<BirdSettingKey, string>> = {
peer_discovery_enabled: peer_discovery_enabled:
'Dynamic neighbor range в BIRD (карантин import/export none). Требует CIDR.', 'Динамический диапазон соседей в BIRD (карантин import/export none). Нужны CIDR.',
peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24', peer_discovery_ranges_v4: 'Через запятую или пробел, напр. 198.51.100.0/24 203.0.113.0/24',
peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32', peer_discovery_ranges_v6: 'Опционально, напр. 2001:db8::/32',
peer_discovery_require_external: 'neighbor range … external (любой чужой ASN)', peer_discovery_require_external: 'Диапазон соседей с внешним (любым чужим) ASN',
} }
export function BirdSettingsTab() { export function BirdSettingsTab() {
@@ -103,8 +103,8 @@ export function BirdSettingsTab() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="BIRD control plane" title="Плоскость управления BIRD"
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator." description="Глобальные параметры BIRD для обновления модулей и применения конфигурации. Сохранение доступно оператору."
footer={ footer={
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}> <LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save /> <Save />
@@ -184,11 +184,11 @@ export function BirdSettingsTab() {
> >
{() => ( {() => (
<SettingsFieldGroup <SettingsFieldGroup
legend="Retention" legend="Хранение"
description="Срок хранения ревизий в минутах." description="Срок хранения ревизий в минутах."
> >
<SettingsSettingField <SettingsSettingField
title="Retention" title="Срок хранения"
description="Сколько минут хранить ревизии конфигурации." description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes" labelFor="revision_retention_minutes"
stacked stacked
@@ -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"
@@ -28,25 +28,25 @@ const ADMIN_SECTIONS = [
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, 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, 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
@@ -54,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) => (
@@ -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 -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 ноды` },
] ]
@@ -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'),
}) })
} }
+9 -9
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>
@@ -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_* и управляются в Настройках BIRD." description="Логи API и конвейера задаются переменной EVOBGP_LOG_* и управляются в Настройках BIRD."
/> />
</TabsContent> </TabsContent>
</BadgeTabs> </BadgeTabs>