fix(web): целостность состояний, русификация обвязки, RBAC-гейтинг навигации, чистка мёртвого кода
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentHasTrafficSample,
|
||||
agentTrafficAccepted,
|
||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
@@ -21,7 +33,7 @@ import {
|
||||
} from '@evofw/ui/components/item'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||
import { formatPackets, formatRelativeTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
||||
@@ -48,20 +60,22 @@ export function AgentCard({
|
||||
dropped === '—' && accepted === '—'
|
||||
? '—'
|
||||
: `↓${dropped} · ↑${accepted}`
|
||||
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
|
||||
const seen = formatRelativeTime(
|
||||
agent.last_seen_at ?? agent.last_apply_at,
|
||||
)
|
||||
const defaultAction =
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||
const subtitle = [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
`поколение ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
value: traffic,
|
||||
valueClass:
|
||||
traffic === '—'
|
||||
@@ -79,7 +93,7 @@ export function AgentCard({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Seen',
|
||||
label: 'Активность',
|
||||
value: seen,
|
||||
valueClass: 'text-muted-foreground',
|
||||
valueNode: seen,
|
||||
@@ -91,6 +105,7 @@ export function AgentCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
aria-label={`Открыть агента ${agent.name}`}
|
||||
className={cn(
|
||||
'w-full text-left outline-none',
|
||||
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
@@ -113,7 +128,10 @@ export function AgentCard({
|
||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={agent} />
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
@@ -173,16 +191,35 @@ export function AgentCard({
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive absolute top-3 right-3 z-10"
|
||||
aria-label="Удалить"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
aria-label={`Действия с агентом ${agent.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVerticalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
|
||||
<PanelRight className="size-4" />
|
||||
Открыть
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -196,7 +196,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
@@ -215,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
Установка
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -247,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
Команда установки
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
@@ -278,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -289,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
@@ -309,14 +309,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
label: 'Ядро',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
label: 'Последнее применение',
|
||||
description: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at
|
||||
? formatRelativeTime(a.last_apply_at)
|
||||
@@ -352,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
{ id: 'host', label: 'Хост-фаервол' },
|
||||
{ id: 'acl', label: 'Правила портов' },
|
||||
{ id: 'hits', label: 'Заблокированное' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
|
||||
@@ -151,12 +151,12 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 130,
|
||||
minSize: 120,
|
||||
maxSize: 150,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
<DataGridColumnHeader column={column} title="Политика" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
@@ -166,19 +166,19 @@ export function AgentFleetDataGrid({
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
{drop ? 'Блокировать' : 'Пропускать'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
<DataGridColumnHeader column={column} title="Применение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -191,7 +191,7 @@ export function AgentFleetDataGrid({
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
Ошибка
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
@@ -203,9 +203,10 @@ export function AgentFleetDataGrid({
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
const status = a.last_apply_status ?? 'ok'
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
{status === 'applied' ? 'Применено' : status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
@@ -218,7 +219,7 @@ export function AgentFleetDataGrid({
|
||||
accessorFn: (row) =>
|
||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Traffic" />
|
||||
<DataGridColumnHeader column={column} title="Трафик" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -265,15 +266,18 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
<DataGridColumnHeader column={column} title="Поколение" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
title="Версия применённой политики — растёт при каждом apply"
|
||||
>
|
||||
{row.original.policy_generation}
|
||||
</span>
|
||||
),
|
||||
@@ -294,7 +298,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
aria-label="Утвердить"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -308,7 +312,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
aria-label="Скопировать команду установки"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
@@ -379,7 +383,7 @@ export function AgentFleetDataGrid({
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||
action: emptyAction,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Agent lifecycle timeline.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
type Step = {
|
||||
title: string
|
||||
date?: string | null
|
||||
detail?: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function formatWhen(iso?: string | null): string | undefined {
|
||||
if (!iso) return undefined
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
|
||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
title: 'Создан (Invited)',
|
||||
date: agent.created_at,
|
||||
detail: 'Install-ссылка выдана',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
title: 'Первый контакт',
|
||||
date: agent.last_seen_at,
|
||||
detail: agent.last_seen_ip
|
||||
? `IP ${agent.last_seen_ip}`
|
||||
: agent.hostname
|
||||
? agent.hostname
|
||||
: 'Ещё не подключался',
|
||||
done: Boolean(agent.last_seen_at),
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
date: agent.approved_at,
|
||||
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
|
||||
done: Boolean(agent.approved_at) || agent.status === 'approved',
|
||||
},
|
||||
{
|
||||
title: 'Last apply',
|
||||
date: agent.last_apply_at,
|
||||
detail: agent.last_apply_error
|
||||
? agent.last_apply_error
|
||||
: (agent.last_apply_status ??
|
||||
(agent.last_apply_prefix_count != null
|
||||
? `${agent.last_apply_prefix_count} prefixes`
|
||||
: undefined)),
|
||||
done: Boolean(agent.last_apply_at),
|
||||
},
|
||||
]
|
||||
|
||||
if (agent.revoked_at || agent.status === 'revoked') {
|
||||
steps.push({
|
||||
title: 'Revoked',
|
||||
date: agent.revoked_at,
|
||||
done: true,
|
||||
})
|
||||
}
|
||||
|
||||
const activeStep = Math.max(
|
||||
1,
|
||||
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Жизненный цикл</FrameTitle>
|
||||
<FrameDescription>
|
||||
Invite → enroll → approve → apply
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<Timeline value={activeStep} className="gap-4 ps-6">
|
||||
{steps.map((s, i) => (
|
||||
<TimelineItem key={s.title} step={i + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{s.title}</TimelineTitle>
|
||||
{s.date ? (
|
||||
<TimelineDate dateTime={s.date}>
|
||||
{formatWhen(s.date)}
|
||||
</TimelineDate>
|
||||
) : (
|
||||
<TimelineDate>—</TimelineDate>
|
||||
)}
|
||||
</TimelineHeader>
|
||||
{s.detail ? (
|
||||
<TimelineContent>{s.detail}</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,17 +25,23 @@ function NavSection({
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
can,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
can: (permission: string) => boolean
|
||||
}) {
|
||||
const visible = items.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
{visible.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
@@ -56,6 +63,7 @@ function NavSection({
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const can = useCan()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -69,6 +77,7 @@ export function AppSidebar() {
|
||||
label={section.label}
|
||||
items={navItemsForSection(section.id)}
|
||||
pathname={pathname}
|
||||
can={can}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@evofw/ui/components/field'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
interface FormFieldSimpleProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
error?: { message?: string }
|
||||
hint?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormFieldSimple({
|
||||
label,
|
||||
htmlFor,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldSimpleProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error} className={cn(className)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
|
||||
<FieldError errors={[error]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
||||
ComponentProps<typeof DataGrid>['tableLayout']
|
||||
>
|
||||
|
||||
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
|
||||
const FILTERS_I18N: Partial<FilterI18nConfig> = {
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Фильтр…',
|
||||
noFieldsFound: 'Фильтры не найдены.',
|
||||
noResultsFound: 'Ничего не найдено.',
|
||||
select: 'Выберите…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'От',
|
||||
max: 'До',
|
||||
to: '—',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
loadingOptions: 'Загрузка…',
|
||||
errorLoadingOptions: 'Не удалось загрузить варианты.',
|
||||
}
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
@@ -463,6 +484,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -512,9 +534,9 @@ export function ResourcePage<T extends object>({
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
pending: 'Pending',
|
||||
invited: 'Invited',
|
||||
approved: 'Approved',
|
||||
revoked: 'Revoked',
|
||||
pending: 'Ожидает одобрения',
|
||||
invited: 'Приглашён',
|
||||
approved: 'Одобрен',
|
||||
revoked: 'Отозван',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Выключен',
|
||||
allow: 'allow',
|
||||
deny: 'deny',
|
||||
allow: 'Разрешить',
|
||||
deny: 'Блокировать',
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
up: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
degraded: 'Замедление',
|
||||
down: 'Недоступен',
|
||||
expired: 'Истёк',
|
||||
static: 'Ручной',
|
||||
domains: 'Ручной',
|
||||
|
||||
Reference in New Issue
Block a user