feat(web): страница агента по доске FwAgentDetail

Карточка агента — отдельная страница вместо Sheet: заголовок с действиями, ошибка применения, полоса показателей и линейные вкладки (политика, хост-фаервол, правила портов, заблокированное). Ссылки ?agent= ведут на страницу.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 20:24:34 +07:00
1 parent f4259246d0
commit 5d767e4341
5 files changed
+316 -550

No files matched your search

@@ -0,0 +1,300 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from '@tanstack/react-router'
import { toast } from 'sonner'
import { useState } from 'react'
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { KpiStatGrid } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { platformLabel } from '@/components/agents/agent-platform-icon'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/agent-traffic'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
type Confirm = 'revoke' | 'delete' | 'reset' | null
/**
* Страница агента по доске FwAgentDetail: заголовок с действиями, ошибка применения,
* полоса показателей и линейные вкладки «Политика», «Хост-фаервол», «Правила портов», «Заблокированное».
*/
export function AgentDetailPage({ agentId }: { agentId: string }) {
const qc = useQueryClient()
const navigate = useNavigate()
const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write')
const agentQ = useQuery(agentQueryOptions(agentId))
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
const [tab, setTab] = useState('policy')
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const [confirm, setConfirm] = useState<Confirm>(null)
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
}
const fail = (e: Error) => toast.error(e.message)
const approve = useMutation({
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
invalidate()
},
onError: fail,
})
const revoke = useMutation({
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
invalidate()
},
onError: fail,
})
const resetStats = useMutation({
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
onSuccess: () => {
toast.success('Статистика сброшена')
invalidate()
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
}
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: fail,
})
const remove = useMutation({
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Агент удалён')
void qc.invalidateQueries({ queryKey: ['agents'] })
void navigate({ to: '/agents', search: { view: 'cards' } })
},
onError: fail,
})
const a = agentQ.data
if (agentQ.isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{agentQ.error?.message ?? 'Не удалось загрузить агента'}</span>
<Button type="button" variant="outline" size="sm" className="w-fit" onClick={() => void agentQ.refetch()}>
Повторить
</Button>
</AlertDescription>
</Alert>
)
}
if (agentQ.isLoading || !a) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-56" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
const isLinux = a.platform === 'linux'
const sub = [
a.hostname,
platformLabel(a.platform),
`поколение ${a.policy_generation}`,
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
]
.filter(Boolean)
.join(' · ')
const confirmCopy: Record<Exclude<Confirm, null>, { title: string; label: string; text: string; run: () => void; pending: boolean }> = {
revoke: {
title: 'Отозвать агента?',
label: 'Отозвать',
text: `Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`,
run: () => revoke.mutate(),
pending: revoke.isPending,
},
delete: {
title: 'Удалить агента?',
label: 'Удалить',
text: `Агент «${a.name}» будет удалён вместе с ссылками и статистикой. Это действие нельзя отменить.`,
run: () => remove.mutate(),
pending: remove.isPending,
},
reset: {
title: 'Сбросить статистику?',
label: 'Сбросить',
text: `Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`,
run: () => resetStats.mutate(),
pending: resetStats.isPending,
},
}
const active = confirm ? confirmCopy[confirm] : null
return (
<>
<Button variant="ghost" size="sm" className="-ml-2 w-fit" render={<Link to="/agents" search={{ view: 'cards' }} />} nativeButton={false}>
<ArrowLeftIcon aria-hidden /> Агенты
</Button>
<PageHead
title={a.name}
sub={sub}
actions={
<>
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} />
</span>
{canWrite ? (
<>
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
IP override
</Button>
<Button variant="outline" onClick={() => setCloneOpen(true)}>
Копировать наборы
</Button>
</>
) : null}
{a.install_curl ? (
<Button
variant="outline"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Команда установки скопирована')
}}
>
Команда установки
</Button>
) : null}
{canWrite && a.status === 'pending' ? (
<Button onClick={() => approve.mutate()} disabled={approve.isPending}>
Утвердить
</Button>
) : null}
{canWrite && a.status === 'approved' ? (
<Button variant="outline" onClick={() => setConfirm('revoke')}>
Отозвать
</Button>
) : null}
{canWrite ? (
<Button variant="outline" className="text-destructive" onClick={() => setConfirm('delete')}>
Удалить агента
</Button>
) : null}
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка применения политики.</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
<KpiStatGrid
aria-label="Показатели агента"
cards={[
{
id: 'traffic',
label: 'Трафик',
value: `↓${formatNumber(agentTrafficDropped(a))} · ↑${formatNumber(agentTrafficAccepted(a))}`,
hint: 'накопительно',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
variant: 'warning',
onClick: canWrite ? () => setConfirm('reset') : undefined,
},
{ id: 'kernel', label: 'Ядро', value: a.last_apply_kernel_method ?? '—', hint: 'метод применения', icon: <CpuIcon aria-hidden />, iconClassName: 'text-info' },
{
id: 'apply',
label: 'Последнее применение',
value: formatDateTime(a.last_apply_at),
hint: a.last_apply_at ? formatRelativeTime(a.last_apply_at) : undefined,
icon: <ClockIcon aria-hidden />,
},
{
id: 'default',
label: 'Если не совпало',
value: a.default_action === 'drop' ? 'Drop' : 'Accept',
hint: 'default action',
icon: <ShieldIcon aria-hidden />,
variant: a.default_action === 'drop' ? 'warning' : 'default',
},
]}
/>
<Tabs value={tab} onValueChange={setTab} className="min-w-0 gap-4">
<PageTabsList>
<PageTab value="policy" label="Политика" />
{isLinux ? <PageTab value="host" label="Хост-фаервол" /> : null}
{isLinux ? <PageTab value="acl" label="Правила портов" /> : null}
<PageTab value="hits" label="Заблокированное" />
</PageTabsList>
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
<div className="@container grid gap-5 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
</div>
<AgentFactsPanel agent={a} />
</div>
<AgentPolicySetsSortable agentId={agentId} />
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
</TabsContent>
{isLinux ? (
<TabsContent value="host" className="min-w-0">
<AgentHostFirewall agentId={agentId} />
</TabsContent>
) : null}
{isLinux ? (
<TabsContent value="acl" className="min-w-0">
<AgentPortAcl agentId={agentId} />
</TabsContent>
) : null}
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
<AgentBlockedIps agentId={agentId} platform={a.platform} />
</TabsContent>
</Tabs>
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
<ConfirmDialog
open={confirm !== null}
onOpenChange={(o) => !o && setConfirm(null)}
title={active?.title ?? ''}
confirmLabel={active?.label ?? ''}
description={active?.text ?? ''}
onConfirm={() => {
active?.run()
setConfirm(null)
}}
disabled={active?.pending}
/>
</>
)
}
@@ -1,103 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { XIcon } from 'lucide-react'
import { AgentDetailView } from '@/components/agents/agent-detail-view'
import { agentQueryOptions } from '@/queries'
import { Button } from '@evofw/ui/components/button'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { cn } from '@evofw/ui/lib/utils'
/**
* Wide agent detail Sheet — inventory-9 / CRM-4 shell + SA3 body.
* Preview: https://reui.io/preview/base/solution-inventory-9
* · https://reui.io/preview/base/solution-crm-4
* · https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/components/c-sheet-1
*/
type AgentDetailSheetProps = {
agentId: string | null
open: boolean
onOpenChange: (open: boolean) => void
onDelete?: (id: string) => void
}
export function AgentDetailSheet({
agentId,
open,
onOpenChange,
onDelete,
}: AgentDetailSheetProps) {
const agentQ = useQuery({
...agentQueryOptions(agentId ?? ''),
enabled: open && Boolean(agentId),
})
const title = agentQ.data?.name ?? 'Агент'
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className={cn(
// Override default data-[side=right]:sm:max-w-sm from @evofw/ui sheet
'sm:max-w-none!',
'inset-y-2 right-2 left-auto flex h-[calc(100svh-1rem)] max-w-none',
'w-[calc(100vw-1rem)] md:w-[calc(100vw-var(--sidebar-width,240px)-1rem)]',
'flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none',
)}
>
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-11 items-center justify-between gap-2 px-4">
<SheetTitle className="truncate text-sm font-medium leading-5">
{title}
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Политика, apply counters и identity агента
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{agentId && open ? (
<AgentDetailView agentId={agentId} onDelete={onDelete} />
) : null}
</ScrollArea>
</div>
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
<SheetClose
render={
<Button type="button" variant="outline" className="w-full">
Закрыть
</Button>
}
/>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -1,424 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useRef, useState } from 'react'
import {
ActivityIcon,
CircleAlertIcon,
ClockIcon,
Copy,
CopyPlusIcon,
CpuIcon,
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
Trash2,
} from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import {
agentTrafficAccepted,
agentTrafficDropped,
} from '@/components/agents/agent-traffic'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
AgentCloneSetsSheet,
AgentOverrideSheet,
} from '@/components/agents/agent-settings-sheets'
import {
agentPreviewQueryOptions,
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { formatDateTime, formatRelativeTime } from '@/lib/format'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { TabsContent } from '@evofw/ui/components/tabs'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
/**
* Full agent detail body — SA3 DNA for Sheet (and redirect target).
* Preview: https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/form-7
*/
type AgentDetailViewProps = {
agentId: string
onDelete?: (id: string) => void
}
export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(agentId))
const previewQ = useQuery(agentPreviewQueryOptions(agentId))
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const [revokeOpen, setRevokeOpen] = useState(false)
const [resetStatsOpen, setResetStatsOpen] = useState(false)
const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const approve = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const resetStats = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
onSuccess: () => {
toast.success('Статистика сброшена')
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
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-recent'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
<div className="flex flex-col gap-4 p-4">
<Skeleton className="h-10 w-56" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (agentQ.isError) {
return (
<div className="p-4">
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void agentQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</div>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`поколение ${a.policy_generation}`,
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
]
.filter(Boolean)
.join(' · ')
return (
<>
<div className="flex flex-col gap-4 p-4">
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={headerDesc}
actions={
<>
<AgentPlatformIcon platform={a.platform} />
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} />
</span>
<Badge
variant={
a.default_action === 'drop'
? 'warning-light'
: 'success-light'
}
size="sm"
radius="full"
>
{a.default_action === 'drop' ? 'Drop' : 'Accept'}
</Badge>
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate()}
disabled={approve.isPending}
>
Утвердить
</Button>
) : null}
{a.status === 'approved' ? (
<Button
variant="outline"
size="sm"
onClick={() => setRevokeOpen(true)}
disabled={revoke.isPending}
>
Отозвать
</Button>
) : null}
{a.install_curl ? (
<Button
variant="outline"
size="sm"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Команда установки скопирована')
}}
>
<Copy data-icon="inline-start" />
Установка
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label="Ещё"
/>
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
<ShieldPlusIcon className="size-4" />
IP override
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
<CopyPlusIcon className="size-4" />
Копировать наборы
</DropdownMenuItem>
{a.install_curl ? (
<DropdownMenuItem
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Команда установки скопирована')
installRef.current?.scrollIntoView({
behavior: 'smooth',
})
}}
>
<TerminalIcon className="size-4" />
Команда установки
</DropdownMenuItem>
) : null}
{onDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(agentId)}
>
<Trash2 className="size-4" />
Удалить агента
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка применения политики</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
<DetailPanel.Metrics
cards={[
{
id: 'traffic',
icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Трафик',
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
hint: 'накопительно',
variant: 'warning',
footer: (
<Button
type="button"
size="sm"
variant="outline"
disabled={resetStats.isPending}
onClick={() => setResetStatsOpen(true)}
>
Сбросить
</Button>
),
},
{
id: 'kernel',
icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Ядро',
description: a.last_apply_kernel_method ?? '—',
},
{
id: 'apply',
icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Последнее применение',
description: formatDateTime(a.last_apply_at),
hint: a.last_apply_at
? formatRelativeTime(a.last_apply_at)
: undefined,
},
]}
/>
<DetailPanel.Section>
<div className="@container flex flex-col gap-4">
<div className="grid gap-4 @4xl:grid-cols-3">
<div className="@4xl:col-span-2">
<AgentPolicyTrace
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
<AgentFactsPanel agent={a} />
</div>
<div ref={installRef}>
<AgentPolicySetsSortable agentId={agentId} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
{a.platform === 'linux' ? (
<div className="flex flex-col gap-3">
<CountedLineTabs
value={fwTab}
onValueChange={setFwTab}
tabs={[
{ id: 'host', label: 'Хост-фаервол' },
{ id: 'acl', label: 'Правила портов' },
{ id: 'hits', label: 'Заблокированное' },
]}
>
<TabsContent value="host" className="mt-3">
<AgentHostFirewall agentId={agentId} />
</TabsContent>
<TabsContent value="acl" className="mt-3">
<AgentPortAcl agentId={agentId} />
</TabsContent>
<TabsContent
value="hits"
className="mt-3 flex flex-col gap-4"
>
<AgentBlockedPorts agentId={agentId} />
<AgentBlockedIps
agentId={agentId}
platform={a.platform}
/>
</TabsContent>
</CountedLineTabs>
</div>
) : (
<AgentBlockedIps agentId={agentId} platform={a.platform} />
)}
</div>
</DetailPanel.Section>
</DetailPanel>
</div>
<AgentOverrideSheet
agentId={agentId}
open={overrideOpen}
onOpenChange={setOverrideOpen}
/>
<AgentCloneSetsSheet
agentId={agentId}
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
<ConfirmDialog
open={revokeOpen}
onOpenChange={setRevokeOpen}
title="Отозвать агента?"
confirmLabel="Отозвать"
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
onConfirm={() => {
setRevokeOpen(false)
revoke.mutate()
}}
disabled={revoke.isPending}
/>
<ConfirmDialog
open={resetStatsOpen}
onOpenChange={setResetStatsOpen}
title="Сбросить статистику?"
confirmLabel="Сбросить"
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
onConfirm={() => {
setResetStatsOpen(false)
resetStats.mutate()
}}
disabled={resetStats.isPending}
/>
</>
)
}
+9 -12
View File
@@ -1,15 +1,12 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
/**
* Legacy deep-link → list + Sheet (`?agent=`).
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
*/
import { createFileRoute } from '@tanstack/react-router'
import { AgentDetailPage } from '@/components/agents/agent-detail-page'
/** Страница агента по доске FwAgentDetail. */
export const Route = createFileRoute('/_auth/agents/$id')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/agents',
search: { agent: params.id, view: 'cards' },
})
},
component: AgentRoute,
})
function AgentRoute() {
const { id } = Route.useParams()
return <AgentDetailPage agentId={id} />
}
+7 -11
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
Check,
@@ -35,7 +35,6 @@ import {
import { Badge } from '@/components/reui/badge'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
import { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
import {
@@ -141,6 +140,12 @@ function AgentsPage() {
const filters = Route.useSearch().f ?? EMPTY_FILTERS
const detailOpen = Boolean(detailAgentId)
// Карточка агента — отдельная страница (доска FwAgentDetail); ссылки ?agent= ведут на неё.
useEffect(() => {
if (detailAgentId) {
void navigate({ to: '/agents/$id', params: { id: detailAgentId }, replace: true })
}
}, [detailAgentId, navigate])
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
const setSearch = useCallback(
@@ -781,15 +786,6 @@ function AgentsPage() {
}}
/>
<AgentDetailSheet
agentId={detailAgentId ?? null}
open={detailOpen}
onOpenChange={(open) => {
if (!open) setSearch({ agent: '' })
}}
onDelete={handleDeleteAgent}
/>
<ConfirmDialog
open={deleteAgentId !== null}
onOpenChange={(open) => {