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