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
+48 -11
View File
@@ -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>
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={agent} />
<StatusBadge status={agent.status} /> <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>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button <Button
type="button" type="button"
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="text-destructive absolute top-3 right-3 z-10" className="absolute top-3 right-3 z-10"
aria-label="Удалить" 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)} onClick={() => onDelete(agent.id)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-4" />
</Button> Удалить
</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>
)
}
+10 -1
View File
@@ -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>
-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, 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>
+8 -8
View File
@@ -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: 'Ручной',
+8
View File
@@ -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',
}, },
] ]
+8 -8
View File
@@ -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}
+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) { 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>