fix(web): update Russian translations and improve UI components
Обновлены переводы на русский язык для различных компонентов, включая KPI, карточки сети и панели управления. Исправлены описания и метки для улучшения пользовательского интерфейса. Также добавлены новые элементы для поддержки локализации в компонентах, таких как DataGrid и ResourcePage. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -51,7 +51,7 @@ export function NetworkOverviewAnalyticsCard({
|
||||
label: 'Offline',
|
||||
percent: 100 - speakersPct,
|
||||
}}
|
||||
footer={`Пиры Established: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
footer={`Пиры установлены: ${net.peersEstablished}/${net.peersEnabled}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ function buildKpis({
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
label: 'Пиры установлены',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
@@ -94,13 +94,13 @@ function buildKpis({
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
label: 'Спикеры в сети',
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
{loading ? '…' : 'онлайн'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { CategoryBadge, ModeBadge } from '@/components/category-badge'
|
||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { CategoryBadge } from '@/components/category-badge'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@evobgp/ui/components/input-group'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import type { ModuleRow } from '@/types/api'
|
||||
import {
|
||||
@@ -51,20 +52,19 @@ import {
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type ModuleSort = 'name' | 'type' | 'priority' | 'last_refreshed_at'
|
||||
type ModuleSort = 'name' | 'type' | 'priority'
|
||||
type EnabledFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
const sortLabels: Record<ModuleSort, string> = {
|
||||
name: 'Название',
|
||||
type: 'Тип',
|
||||
priority: 'Приоритет',
|
||||
last_refreshed_at: 'Обновлено',
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGE = 'Нет модулей по выбранным фильтрам.'
|
||||
|
||||
function buildSorting(sortBy: ModuleSort): SortingState {
|
||||
return [{ id: sortBy, desc: sortBy === 'last_refreshed_at' }]
|
||||
return [{ id: sortBy, desc: false }]
|
||||
}
|
||||
|
||||
export function DashboardModulesGrid({
|
||||
@@ -138,30 +138,9 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
size: 88,
|
||||
meta: { headerTitle: 'Приоритет' },
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorFn: (row) => (row.enabled !== false ? 'enabled' : 'disabled'),
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <ModeBadge enabled={row.original.enabled !== false} />,
|
||||
meta: { headerTitle: 'Состояние' },
|
||||
},
|
||||
{
|
||||
id: 'last_refreshed_at',
|
||||
accessorFn: (row) => row.last_refreshed_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Обновлено" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_refreshed_at
|
||||
? new Date(row.original.last_refreshed_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
sortingFn: (a, b) =>
|
||||
(a.original.last_refreshed_at ?? '').localeCompare(b.original.last_refreshed_at ?? ''),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -337,8 +316,8 @@ export function DashboardModulesGrid({
|
||||
<div className="border-t px-4 py-3">
|
||||
{filteredModules.length > 0 ? (
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[10, 15, 20]}
|
||||
info="{from}–{to} из {count}"
|
||||
className="py-0"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DashboardNetworkHealth({
|
||||
}
|
||||
primary={{
|
||||
value: loading ? '—' : `${utilization}%`,
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры online',
|
||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||
percent: loading ? 0 : utilization,
|
||||
badge: (
|
||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
||||
@@ -67,7 +67,7 @@ export function DashboardNetworkHealth({
|
||||
}}
|
||||
secondary={{
|
||||
value: loading ? '—' : offline,
|
||||
label: mode === 'peers' ? 'Не Established' : 'Offline',
|
||||
label: mode === 'peers' ? 'Не установлены' : 'Не в сети',
|
||||
percent: total > 0 ? Math.round((offline / total) * 100) : 0,
|
||||
}}
|
||||
footer={
|
||||
|
||||
@@ -7,7 +7,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'lookup',
|
||||
title: 'Проверка IP/домена',
|
||||
description: 'Membership в списках и community (entry + snapshot).',
|
||||
description: 'Проверка IP в списках и BGP-сообществах.',
|
||||
to: '/lookup',
|
||||
icon: <Search aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -23,7 +23,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'communities',
|
||||
title: 'BGP-сообщества',
|
||||
description: 'Справочник communities для политик экспорта.',
|
||||
description: 'Справочник BGP-сообществ для политик экспорта.',
|
||||
to: '/directories',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -31,7 +31,7 @@ const ACTIONS: QuickActionItem[] = [
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Сеть',
|
||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||
description: 'Обзор пиров, спикеров и живых BGP-сессий.',
|
||||
to: '/network',
|
||||
search: { tab: 'overview' },
|
||||
icon: <Network aria-hidden />,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NetworkKpi({
|
||||
id: 'peers-established',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
label: 'Пиры Established',
|
||||
label: 'Пиры установлены',
|
||||
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
@@ -44,7 +44,7 @@ export function NetworkKpi({
|
||||
id: 'speakers-online',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Спикеры online',
|
||||
label: 'Спикеры в сети',
|
||||
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||
footer: (
|
||||
<Badge
|
||||
@@ -55,7 +55,7 @@ export function NetworkKpi({
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live'}
|
||||
{loading ? '…' : 'онлайн'}
|
||||
</Badge>
|
||||
),
|
||||
to: '/network',
|
||||
|
||||
@@ -32,13 +32,13 @@ function peerDeleteLabel(p: PeerRow): string {
|
||||
|
||||
const PEER_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'established', label: 'Established' },
|
||||
{ id: 'established', label: 'Установлены' },
|
||||
{ id: 'pending', label: 'Ожидание' },
|
||||
{ id: 'disabled', label: 'Выключены' },
|
||||
]
|
||||
|
||||
const SESSION_STATE_OPTIONS = [
|
||||
{ value: 'Established', label: 'Established' },
|
||||
{ value: 'Established', label: 'Установлена' },
|
||||
{ value: 'Idle', label: 'Idle' },
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Connect', label: 'Connect' },
|
||||
|
||||
@@ -72,7 +72,7 @@ export function PeerFormDialog({
|
||||
}
|
||||
const asn = Number(remoteAsn)
|
||||
if (!asn || asn <= 0) {
|
||||
toast.error('Remote ASN должен быть больше 0')
|
||||
toast.error('ASN соседа должен быть больше 0')
|
||||
return
|
||||
}
|
||||
const body: BgpPeerCreate = {
|
||||
@@ -132,7 +132,7 @@ export function PeerFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="peer-asn">Remote ASN</Label>
|
||||
<Label htmlFor="peer-asn">ASN соседа</Label>
|
||||
<Input
|
||||
id="peer-asn"
|
||||
type="number"
|
||||
|
||||
@@ -6,5 +6,5 @@ export function kpiCols(count: number): string {
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
return 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
||||
import { FILTERS_I18N_RU } from '@/lib/filters-i18n'
|
||||
import { applyFiltersToData } from './filter-utils'
|
||||
|
||||
export interface ResourcePageTab {
|
||||
@@ -322,6 +324,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N_RU}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -355,11 +358,8 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
<FrameFooter>
|
||||
<DataGridPagination
|
||||
{...DATA_GRID_PAGINATION_RU}
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -43,16 +43,21 @@ interface SettingsShellProps {
|
||||
*/
|
||||
export function SettingsShell({
|
||||
title = 'Настройки',
|
||||
description = 'Подключение UI и параметры плоскости управления',
|
||||
description,
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const headerDescription =
|
||||
description ??
|
||||
(pathname.startsWith('/tenant-settings')
|
||||
? 'Глобальные параметры BIRD и плоскости управления'
|
||||
: 'Подключение UI и параметры плоскости управления')
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<PageHeader title={title} description={description} />
|
||||
<PageHeader title={title} description={headerDescription} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -133,7 +133,9 @@ export function ScheduleAgendaPanel({
|
||||
</div>
|
||||
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{FILTER_ITEMS.find((item) => item.value === filter)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DEFAULT_I18N,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
|
||||
const OPERATOR_LABEL_RU: Record<string, string> = {
|
||||
is: 'равно',
|
||||
is_not: 'не равно',
|
||||
is_any_of: 'любое из',
|
||||
is_not_any_of: 'кроме',
|
||||
includes_all: 'включает все',
|
||||
excludes_all: 'исключает все',
|
||||
before: 'до',
|
||||
after: 'после',
|
||||
between: 'между',
|
||||
not_between: 'вне диапазона',
|
||||
contains: 'содержит',
|
||||
not_contains: 'не содержит',
|
||||
starts_with: 'начинается с',
|
||||
ends_with: 'заканчивается на',
|
||||
equals: 'равно',
|
||||
not_equals: 'не равно',
|
||||
greater_than: 'больше',
|
||||
less_than: 'меньше',
|
||||
overlaps: 'пересекается',
|
||||
includes: 'включает',
|
||||
excludes: 'исключает',
|
||||
empty: 'пусто',
|
||||
not_empty: 'не пусто',
|
||||
}
|
||||
|
||||
export const FILTERS_I18N_RU: FilterI18nConfig = {
|
||||
...DEFAULT_I18N,
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Фильтр…',
|
||||
noFieldsFound: 'Поля не найдены.',
|
||||
noResultsFound: 'Ничего не найдено.',
|
||||
select: 'Выбрать…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'Мин.',
|
||||
max: 'Макс.',
|
||||
to: '—',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
operators: {
|
||||
...DEFAULT_I18N.operators,
|
||||
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: {
|
||||
enterField: (fieldType: string) => `Введите ${fieldType}…`,
|
||||
selectField: 'Выбрать…',
|
||||
searchField: (fieldName: string) => `Поиск ${fieldName.toLowerCase()}…`,
|
||||
enterKey: 'Введите ключ…',
|
||||
enterValue: 'Введите значение…',
|
||||
},
|
||||
helpers: {
|
||||
formatOperator: (operator: string) =>
|
||||
OPERATOR_LABEL_RU[operator] ?? operator.replace(/_/g, ' '),
|
||||
},
|
||||
validation: {
|
||||
invalidEmail: 'Некорректный email',
|
||||
invalidUrl: 'Некорректный URL',
|
||||
invalidTel: 'Некорректный телефон',
|
||||
invalid: 'Некорректное значение',
|
||||
},
|
||||
}
|
||||
@@ -64,7 +64,7 @@ function AccessComponent() {
|
||||
variant: revokedCount > 0 ? 'warning' : 'default',
|
||||
footer: (
|
||||
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||
revoked
|
||||
отозваны
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user