fix(web): целостность состояний, русификация обвязки, RBAC-гейтинг навигации, чистка мёртвого кода
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
import { Trash2 } from 'lucide-react'
|
import {
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PanelRight,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
AgentPlatformIcon,
|
AgentPlatformIcon,
|
||||||
platformLabel,
|
platformLabel,
|
||||||
} from '@/components/agents/agent-platform-icon'
|
} from '@/components/agents/agent-platform-icon'
|
||||||
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
import {
|
import {
|
||||||
agentHasTrafficSample,
|
agentHasTrafficSample,
|
||||||
agentTrafficAccepted,
|
agentTrafficAccepted,
|
||||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evofw/ui/components/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
@@ -21,7 +33,7 @@ import {
|
|||||||
} from '@evofw/ui/components/item'
|
} from '@evofw/ui/components/item'
|
||||||
import { Separator } from '@evofw/ui/components/separator'
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
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.
|
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
||||||
@@ -48,20 +60,22 @@ export function AgentCard({
|
|||||||
dropped === '—' && accepted === '—'
|
dropped === '—' && accepted === '—'
|
||||||
? '—'
|
? '—'
|
||||||
: `↓${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 =
|
const defaultAction =
|
||||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||||
const subtitle = [
|
const subtitle = [
|
||||||
agent.hostname,
|
agent.hostname,
|
||||||
platformLabel(agent.platform),
|
platformLabel(agent.platform),
|
||||||
`gen ${agent.policy_generation}`,
|
`поколение ${agent.policy_generation}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
label: 'Traffic',
|
label: 'Трафик',
|
||||||
value: traffic,
|
value: traffic,
|
||||||
valueClass:
|
valueClass:
|
||||||
traffic === '—'
|
traffic === '—'
|
||||||
@@ -79,7 +93,7 @@ export function AgentCard({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Seen',
|
label: 'Активность',
|
||||||
value: seen,
|
value: seen,
|
||||||
valueClass: 'text-muted-foreground',
|
valueClass: 'text-muted-foreground',
|
||||||
valueNode: seen,
|
valueNode: seen,
|
||||||
@@ -91,6 +105,7 @@ export function AgentCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(agent.id)}
|
onClick={() => onSelect(agent.id)}
|
||||||
|
aria-label={`Открыть агента ${agent.name}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left outline-none',
|
'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',
|
'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">
|
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</h3>
|
</h3>
|
||||||
<StatusBadge status={agent.status} />
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AgentOnlineDot agent={agent} />
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
agent.default_action === 'drop'
|
agent.default_action === 'drop'
|
||||||
@@ -173,16 +191,35 @@ export function AgentCard({
|
|||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger
|
||||||
size="icon-sm"
|
render={
|
||||||
variant="ghost"
|
<Button
|
||||||
className="text-destructive absolute top-3 right-3 z-10"
|
type="button"
|
||||||
aria-label="Удалить"
|
size="icon-sm"
|
||||||
onClick={() => onDelete(agent.id)}
|
variant="ghost"
|
||||||
>
|
className="absolute top-3 right-3 z-10"
|
||||||
<Trash2 className="size-3.5" />
|
aria-label={`Действия с агентом ${agent.name}`}
|
||||||
</Button>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
@@ -196,7 +196,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
onClick={() => approve.mutate()}
|
onClick={() => approve.mutate()}
|
||||||
disabled={approve.isPending}
|
disabled={approve.isPending}
|
||||||
>
|
>
|
||||||
Approve
|
Утвердить
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{a.status === 'approved' ? (
|
{a.status === 'approved' ? (
|
||||||
@@ -215,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(a.install_curl!)
|
copyToClipboard(a.install_curl!)
|
||||||
toast.success('Скопировано')
|
toast.success('Команда установки скопирована')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
Install
|
Установка
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -247,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(a.install_curl!)
|
copyToClipboard(a.install_curl!)
|
||||||
toast.success('Скопировано')
|
toast.success('Команда установки скопирована')
|
||||||
installRef.current?.scrollIntoView({
|
installRef.current?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TerminalIcon className="size-4" />
|
<TerminalIcon className="size-4" />
|
||||||
Install curl
|
Команда установки
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
@@ -278,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
{a.last_apply_error ? (
|
{a.last_apply_error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlertIcon />
|
<CircleAlertIcon />
|
||||||
<AlertTitle>Ошибка apply</AlertTitle>
|
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -289,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
id: 'traffic',
|
id: 'traffic',
|
||||||
icon: <ActivityIcon aria-hidden />,
|
icon: <ActivityIcon aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
label: 'Traffic',
|
label: 'Трафик',
|
||||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||||
hint: 'накопительно',
|
hint: 'накопительно',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
@@ -309,14 +309,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
id: 'kernel',
|
id: 'kernel',
|
||||||
icon: <CpuIcon aria-hidden />,
|
icon: <CpuIcon aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
label: 'Kernel',
|
label: 'Ядро',
|
||||||
description: a.last_apply_kernel_method ?? '—',
|
description: a.last_apply_kernel_method ?? '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apply',
|
id: 'apply',
|
||||||
icon: <ClockIcon aria-hidden />,
|
icon: <ClockIcon aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
label: 'Last apply',
|
label: 'Последнее применение',
|
||||||
description: formatDateTime(a.last_apply_at),
|
description: formatDateTime(a.last_apply_at),
|
||||||
hint: a.last_apply_at
|
hint: a.last_apply_at
|
||||||
? formatRelativeTime(a.last_apply_at)
|
? formatRelativeTime(a.last_apply_at)
|
||||||
@@ -352,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
value={fwTab}
|
value={fwTab}
|
||||||
onValueChange={setFwTab}
|
onValueChange={setFwTab}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'host', label: 'Host firewall' },
|
{ id: 'host', label: 'Хост-фаервол' },
|
||||||
{ id: 'acl', label: 'Port ACL' },
|
{ id: 'acl', label: 'Правила портов' },
|
||||||
{ id: 'hits', label: 'Blocked' },
|
{ id: 'hits', label: 'Заблокированное' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="host" className="mt-3">
|
<TabsContent value="host" className="mt-3">
|
||||||
|
|||||||
@@ -151,12 +151,12 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'default_action',
|
id: 'default_action',
|
||||||
size: 100,
|
size: 130,
|
||||||
minSize: 90,
|
minSize: 120,
|
||||||
maxSize: 120,
|
maxSize: 150,
|
||||||
accessorFn: (row) => row.default_action,
|
accessorFn: (row) => row.default_action,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Default" />
|
<DataGridColumnHeader column={column} title="Политика" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const drop = row.original.default_action === 'drop'
|
const drop = row.original.default_action === 'drop'
|
||||||
@@ -166,19 +166,19 @@ export function AgentFleetDataGrid({
|
|||||||
size="sm"
|
size="sm"
|
||||||
radius="full"
|
radius="full"
|
||||||
>
|
>
|
||||||
{drop ? 'Drop' : 'Accept'}
|
{drop ? 'Блокировать' : 'Пропускать'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apply',
|
id: 'apply',
|
||||||
size: 100,
|
size: 110,
|
||||||
minSize: 90,
|
minSize: 100,
|
||||||
maxSize: 120,
|
maxSize: 130,
|
||||||
accessorFn: (row) => row.last_apply_status ?? '',
|
accessorFn: (row) => row.last_apply_status ?? '',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Apply" />
|
<DataGridColumnHeader column={column} title="Применение" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
@@ -191,7 +191,7 @@ export function AgentFleetDataGrid({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge variant="destructive-light" size="sm">
|
<Badge variant="destructive-light" size="sm">
|
||||||
error
|
Ошибка
|
||||||
</Badge>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-sm">
|
<TooltipContent className="max-w-sm">
|
||||||
@@ -203,9 +203,10 @@ export function AgentFleetDataGrid({
|
|||||||
if (!a.last_apply_status && !a.last_apply_at) {
|
if (!a.last_apply_status && !a.last_apply_at) {
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
|
const status = a.last_apply_status ?? 'ok'
|
||||||
return (
|
return (
|
||||||
<Badge variant="secondary" size="sm">
|
<Badge variant="secondary" size="sm">
|
||||||
{a.last_apply_status ?? 'ok'}
|
{status === 'applied' ? 'Применено' : status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -218,7 +219,7 @@ export function AgentFleetDataGrid({
|
|||||||
accessorFn: (row) =>
|
accessorFn: (row) =>
|
||||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Traffic" />
|
<DataGridColumnHeader column={column} title="Трафик" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
@@ -265,15 +266,18 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gen',
|
id: 'gen',
|
||||||
size: 70,
|
size: 110,
|
||||||
minSize: 60,
|
minSize: 100,
|
||||||
maxSize: 90,
|
maxSize: 130,
|
||||||
accessorFn: (row) => row.policy_generation,
|
accessorFn: (row) => row.policy_generation,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Gen" />
|
<DataGridColumnHeader column={column} title="Поколение" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
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}
|
{row.original.policy_generation}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -294,7 +298,7 @@ export function AgentFleetDataGrid({
|
|||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Approve"
|
aria-label="Утвердить"
|
||||||
disabled={approvePending}
|
disabled={approvePending}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -308,7 +312,7 @@ export function AgentFleetDataGrid({
|
|||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Copy install"
|
aria-label="Скопировать команду установки"
|
||||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||||
>
|
>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
@@ -379,7 +383,7 @@ export function AgentFleetDataGrid({
|
|||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет агентов',
|
title: 'Нет агентов',
|
||||||
description:
|
description:
|
||||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||||
action: emptyAction,
|
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 { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { NavUser } from '@/components/layout/nav-user'
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||||
|
import { useCan } from '@/lib/permissions'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -24,17 +25,23 @@ function NavSection({
|
|||||||
label,
|
label,
|
||||||
items,
|
items,
|
||||||
pathname,
|
pathname,
|
||||||
|
can,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
items: readonly NavItem[]
|
items: readonly NavItem[]
|
||||||
pathname: string
|
pathname: string
|
||||||
|
can: (permission: string) => boolean
|
||||||
}) {
|
}) {
|
||||||
|
const visible = items.filter(
|
||||||
|
(item) => !item.permission || can(item.permission),
|
||||||
|
)
|
||||||
|
if (visible.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{visible.map((item) => (
|
||||||
<SidebarMenuItem key={item.to}>
|
<SidebarMenuItem key={item.to}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={item.label}
|
tooltip={item.label}
|
||||||
@@ -56,6 +63,7 @@ function NavSection({
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const can = useCan()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
@@ -69,6 +77,7 @@ export function AppSidebar() {
|
|||||||
label={section.label}
|
label={section.label}
|
||||||
items={navItemsForSection(section.id)}
|
items={navItemsForSection(section.id)}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
can={can}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</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,
|
Filters,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
|
type FilterI18nConfig,
|
||||||
} from '@/components/reui/filters'
|
} from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
|||||||
ComponentProps<typeof DataGrid>['tableLayout']
|
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 {
|
export interface ResourcePageTab {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
@@ -463,6 +484,7 @@ export function ResourcePage<T extends object>({
|
|||||||
fields={filterFields}
|
fields={filterFields}
|
||||||
onChange={handleFiltersChange}
|
onChange={handleFiltersChange}
|
||||||
size="default"
|
size="default"
|
||||||
|
i18n={FILTERS_I18N}
|
||||||
trigger={
|
trigger={
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
@@ -512,9 +534,9 @@ export function ResourcePage<T extends object>({
|
|||||||
<DataGridPagination
|
<DataGridPagination
|
||||||
sizes={[5, 10, 20, 50]}
|
sizes={[5, 10, 20, 50]}
|
||||||
rowsPerPageLabel="Строк на странице"
|
rowsPerPageLabel="Строк на странице"
|
||||||
info="{from} - {to} of {count}"
|
info="{from}–{to} из {count}"
|
||||||
previousPageLabel="Предыдущая"
|
previousPageLabel="Назад"
|
||||||
nextPageLabel="Следующая"
|
nextPageLabel="Вперёд"
|
||||||
/>
|
/>
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
|||||||
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
active: 'Активен',
|
active: 'Активен',
|
||||||
synced: 'Синхронизировано',
|
synced: 'Синхронизировано',
|
||||||
pending_push: 'Ожидает отправки',
|
pending_push: 'Ожидает отправки',
|
||||||
pending: 'Pending',
|
pending: 'Ожидает одобрения',
|
||||||
invited: 'Invited',
|
invited: 'Приглашён',
|
||||||
approved: 'Approved',
|
approved: 'Одобрен',
|
||||||
revoked: 'Revoked',
|
revoked: 'Отозван',
|
||||||
enabled: 'Включён',
|
enabled: 'Включён',
|
||||||
disabled: 'Выключен',
|
disabled: 'Выключен',
|
||||||
allow: 'allow',
|
allow: 'Разрешить',
|
||||||
deny: 'deny',
|
deny: 'Блокировать',
|
||||||
conflict: 'Конфликт',
|
conflict: 'Конфликт',
|
||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
ok: 'OK',
|
ok: 'OK',
|
||||||
up: 'OK',
|
up: 'OK',
|
||||||
warning: 'Предупреждение',
|
warning: 'Предупреждение',
|
||||||
degraded: 'Slow',
|
degraded: 'Замедление',
|
||||||
down: 'Down',
|
down: 'Недоступен',
|
||||||
expired: 'Истёк',
|
expired: 'Истёк',
|
||||||
static: 'Ручной',
|
static: 'Ручной',
|
||||||
domains: 'Ручной',
|
domains: 'Ручной',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type NavItem = {
|
|||||||
keywords: string[]
|
keywords: string[]
|
||||||
icon: typeof ServerIcon
|
icon: typeof ServerIcon
|
||||||
section: NavSectionId
|
section: NavSectionId
|
||||||
|
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
|
||||||
|
permission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: readonly NavItem[] = [
|
export const NAV_ITEMS: readonly NavItem[] = [
|
||||||
@@ -32,6 +34,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['dashboard', 'панель', 'обзор'],
|
keywords: ['dashboard', 'панель', 'обзор'],
|
||||||
icon: LayoutDashboardIcon,
|
icon: LayoutDashboardIcon,
|
||||||
section: 'overview',
|
section: 'overview',
|
||||||
|
permission: 'fw:dashboard:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/agents',
|
to: '/agents',
|
||||||
@@ -39,6 +42,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['agents', 'агенты', 'nodes'],
|
keywords: ['agents', 'агенты', 'nodes'],
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
section: 'ops',
|
section: 'ops',
|
||||||
|
permission: 'fw:agents:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/lists',
|
to: '/lists',
|
||||||
@@ -46,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['lists', 'списки', 'blocklist'],
|
keywords: ['lists', 'списки', 'blocklist'],
|
||||||
icon: ListIcon,
|
icon: ListIcon,
|
||||||
section: 'ops',
|
section: 'ops',
|
||||||
|
permission: 'fw:lists:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/rules',
|
to: '/rules',
|
||||||
@@ -53,6 +58,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||||
icon: ShieldIcon,
|
icon: ShieldIcon,
|
||||||
section: 'ops',
|
section: 'ops',
|
||||||
|
permission: 'fw:policies:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/stats',
|
to: '/stats',
|
||||||
@@ -60,6 +66,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['stats', 'статистика', 'packets'],
|
keywords: ['stats', 'статистика', 'packets'],
|
||||||
icon: BarChart3Icon,
|
icon: BarChart3Icon,
|
||||||
section: 'ops',
|
section: 'ops',
|
||||||
|
permission: 'fw:stats:read',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
@@ -67,6 +74,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
keywords: ['settings', 'настройки'],
|
keywords: ['settings', 'настройки'],
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
section: 'system',
|
section: 'system',
|
||||||
|
permission: 'fw:settings:read',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ function AgentsPage() {
|
|||||||
{
|
{
|
||||||
id: 'add',
|
id: 'add',
|
||||||
title: 'Добавить агента',
|
title: 'Добавить агента',
|
||||||
description: 'Invite + install one-liner',
|
description: 'Приглашение и команда установки',
|
||||||
icon: <Plus aria-hidden />,
|
icon: <Plus aria-hidden />,
|
||||||
iconClassName: 'text-primary [&_svg]:text-current',
|
iconClassName: 'text-primary [&_svg]:text-current',
|
||||||
badgeLabel: 'Открыть',
|
badgeLabel: 'Открыть',
|
||||||
@@ -467,8 +467,8 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pending',
|
id: 'pending',
|
||||||
title: 'Pending',
|
title: 'Ожидают одобрения',
|
||||||
description: `${counts.pending} ждут approve`,
|
description: `${counts.pending} ждут подтверждения`,
|
||||||
icon: <Inbox aria-hidden />,
|
icon: <Inbox aria-hidden />,
|
||||||
iconClassName: 'text-warning [&_svg]:text-current',
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
badgeLabel: 'Показать',
|
badgeLabel: 'Показать',
|
||||||
@@ -477,7 +477,7 @@ function AgentsPage() {
|
|||||||
{
|
{
|
||||||
id: 'rules',
|
id: 'rules',
|
||||||
title: 'Наборы правил',
|
title: 'Наборы правил',
|
||||||
description: 'Политика firewall',
|
description: 'Политика фаервола',
|
||||||
to: '/rules',
|
to: '/rules',
|
||||||
icon: <ShieldIcon aria-hidden />,
|
icon: <ShieldIcon aria-hidden />,
|
||||||
iconClassName: 'text-info [&_svg]:text-current',
|
iconClassName: 'text-info [&_svg]:text-current',
|
||||||
@@ -578,7 +578,7 @@ function AgentsPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Агенты"
|
title="Агенты"
|
||||||
description="Ops console: invite, install, approve"
|
description="Приглашение, установка и одобрение агентов"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||||
@@ -595,9 +595,9 @@ function AgentsPage() {
|
|||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
<FrameTitle>Attention — pending approve</FrameTitle>
|
<FrameTitle>Требуют одобрения</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
|
{counts.pending} агент(ов) ждут одобрения
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
@@ -717,7 +717,7 @@ function AgentsPage() {
|
|||||||
}
|
}
|
||||||
emptyDescription={
|
emptyDescription={
|
||||||
items.length === 0
|
items.length === 0
|
||||||
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
emptyAction={items.length === 0 ? addButton : undefined}
|
emptyAction={items.length === 0 ? addButton : undefined}
|
||||||
|
|||||||
@@ -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) {
|
if (!detail) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
|
CircleAlertIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
@@ -9,6 +10,11 @@ import {
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
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) {
|
if (!setQ.data) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user