fix(web): целостность состояний, русификация обвязки, RBAC-гейтинг навигации, чистка мёртвого кода

This commit is contained in:
Denozordec
2026-09-25 01:26:14 +07:00
parent 0b426fa7ae
commit 28f1f35f16
12 changed files with 205 additions and 228 deletions
+55 -18
View File
@@ -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>
)
}
+10 -1
View File
@@ -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>
-35
View File
@@ -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>
+8 -8
View File
@@ -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: 'Ручной',
+8
View File
@@ -22,6 +22,8 @@ export type NavItem = {
keywords: string[]
icon: typeof ServerIcon
section: NavSectionId
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
permission?: string
}
export const NAV_ITEMS: readonly NavItem[] = [
@@ -32,6 +34,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
section: 'overview',
permission: 'fw:dashboard:read',
},
{
to: '/agents',
@@ -39,6 +42,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
section: 'ops',
permission: 'fw:agents:read',
},
{
to: '/lists',
@@ -46,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
section: 'ops',
permission: 'fw:lists:read',
},
{
to: '/rules',
@@ -53,6 +58,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
section: 'ops',
permission: 'fw:policies:read',
},
{
to: '/stats',
@@ -60,6 +66,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
section: 'ops',
permission: 'fw:stats:read',
},
{
to: '/settings',
@@ -67,6 +74,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
section: 'system',
permission: 'fw:settings:read',
},
]
+8 -8
View File
@@ -459,7 +459,7 @@ function AgentsPage() {
{
id: 'add',
title: 'Добавить агента',
description: 'Invite + install one-liner',
description: 'Приглашение и команда установки',
icon: <Plus aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Открыть',
@@ -467,8 +467,8 @@ function AgentsPage() {
},
{
id: 'pending',
title: 'Pending',
description: `${counts.pending} ждут approve`,
title: 'Ожидают одобрения',
description: `${counts.pending} ждут подтверждения`,
icon: <Inbox aria-hidden />,
iconClassName: 'text-warning [&_svg]:text-current',
badgeLabel: 'Показать',
@@ -477,7 +477,7 @@ function AgentsPage() {
{
id: 'rules',
title: 'Наборы правил',
description: 'Политика firewall',
description: 'Политика фаервола',
to: '/rules',
icon: <ShieldIcon aria-hidden />,
iconClassName: 'text-info [&_svg]:text-current',
@@ -578,7 +578,7 @@ function AgentsPage() {
<PageShell>
<PageHeader
title="Агенты"
description="Ops console: invite, install, approve"
description="Приглашение, установка и одобрение агентов"
actions={
<>
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
@@ -595,9 +595,9 @@ function AgentsPage() {
<Frame dense spacing="sm">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Attention — pending approve</FrameTitle>
<FrameTitle>Требуют одобрения</FrameTitle>
<FrameDescription>
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
{counts.pending} агент(ов) ждут одобрения
</FrameDescription>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
@@ -717,7 +717,7 @@ function AgentsPage() {
}
emptyDescription={
items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
: undefined
}
emptyAction={items.length === 0 ? addButton : undefined}
+23
View File
@@ -311,6 +311,29 @@ function ListDetailPage() {
)
}
if (listQ.isError) {
return (
<PageShell>
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{listQ.error?.message ?? 'Не удалось загрузить список'}</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void listQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</PageShell>
)
}
if (!detail) {
return (
<PageShell>
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
CircleAlertIcon,
ListIcon,
ShieldIcon,
UsersIcon,
@@ -9,6 +10,11 @@ import {
import { useMemo, useState } from 'react'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { PageShell, DetailPanel } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
@@ -271,6 +277,31 @@ function PolicySetDetailPage() {
)
}
if (setQ.isError) {
return (
<PageShell>
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>
{setQ.error?.message ?? 'Не удалось загрузить набор правил'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void setQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</PageShell>
)
}
if (!setQ.data) {
return (
<PageShell>