From 5d767e4341a49b17c003d987b5eff68995a12ac0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 20:24:34 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B5=20FwAgentDetail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка агента — отдельная страница вместо Sheet: заголовок с действиями, ошибка применения, полоса показателей и линейные вкладки (политика, хост-фаервол, правила портов, заблокированное). Ссылки ?agent= ведут на страницу. Co-Authored-By: Claude Sonnet 5.5 --- .../components/agents/agent-detail-page.tsx | 300 +++++++++++++ .../components/agents/agent-detail-sheet.tsx | 103 ----- .../components/agents/agent-detail-view.tsx | 424 ------------------ apps/web/src/routes/_auth/agents/$id.tsx | 21 +- apps/web/src/routes/_auth/agents/index.tsx | 18 +- 5 files changed, 316 insertions(+), 550 deletions(-) create mode 100644 apps/web/src/components/agents/agent-detail-page.tsx delete mode 100644 apps/web/src/components/agents/agent-detail-sheet.tsx delete mode 100644 apps/web/src/components/agents/agent-detail-view.tsx diff --git a/apps/web/src/components/agents/agent-detail-page.tsx b/apps/web/src/components/agents/agent-detail-page.tsx new file mode 100644 index 0000000..7e19cba --- /dev/null +++ b/apps/web/src/components/agents/agent-detail-page.tsx @@ -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(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 ( + + + Ошибка загрузки + + {agentQ.error?.message ?? 'Не удалось загрузить агента'} + + + + ) + } + + if (agentQ.isLoading || !a) { + return ( +
+ + + +
+ ) + } + + 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, { 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 ( + <> + + + + + + + {canWrite ? ( + <> + + + + ) : null} + {a.install_curl ? ( + + ) : null} + {canWrite && a.status === 'pending' ? ( + + ) : null} + {canWrite && a.status === 'approved' ? ( + + ) : null} + {canWrite ? ( + + ) : null} + + } + /> + + {a.last_apply_error ? ( + + + Ошибка применения политики. + {a.last_apply_error} + + ) : null} + + , + iconClassName: 'text-warning', + variant: 'warning', + onClick: canWrite ? () => setConfirm('reset') : undefined, + }, + { id: 'kernel', label: 'Ядро', value: a.last_apply_kernel_method ?? '—', hint: 'метод применения', icon: , iconClassName: 'text-info' }, + { + id: 'apply', + label: 'Последнее применение', + value: formatDateTime(a.last_apply_at), + hint: a.last_apply_at ? formatRelativeTime(a.last_apply_at) : undefined, + icon: , + }, + { + id: 'default', + label: 'Если не совпало', + value: a.default_action === 'drop' ? 'Drop' : 'Accept', + hint: 'default action', + icon: , + variant: a.default_action === 'drop' ? 'warning' : 'default', + }, + ]} + /> + + + + + {isLinux ? : null} + {isLinux ? : null} + + + +
+
+ +
+ +
+ + +
+ {isLinux ? ( + + + + ) : null} + {isLinux ? ( + + + + ) : null} + + {isLinux ? : null} + + +
+ + + + !o && setConfirm(null)} + title={active?.title ?? ''} + confirmLabel={active?.label ?? ''} + description={active?.text ?? ''} + onConfirm={() => { + active?.run() + setConfirm(null) + }} + disabled={active?.pending} + /> + + ) +} diff --git a/apps/web/src/components/agents/agent-detail-sheet.tsx b/apps/web/src/components/agents/agent-detail-sheet.tsx deleted file mode 100644 index f3cefad..0000000 --- a/apps/web/src/components/agents/agent-detail-sheet.tsx +++ /dev/null @@ -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 ( - - - -
- - {title} - - -
- - Политика, apply counters и identity агента - -
- -
- - {agentId && open ? ( - - ) : null} - -
- - - - Закрыть - - } - /> - -
-
- ) -} diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx deleted file mode 100644 index c88f76f..0000000 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ /dev/null @@ -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(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 ( -
- - - -
- ) - } - - if (agentQ.isError) { - return ( -
- - - Ошибка загрузки - - - {agentQ.error?.message ?? 'Не удалось загрузить агента'} - - - - -
- ) - } - - const headerDesc = [ - a.hostname, - platformLabel(a.platform), - `поколение ${a.policy_generation}`, - a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать', - ] - .filter(Boolean) - .join(' · ') - - return ( - <> -
- - - - - - - - - {a.default_action === 'drop' ? 'Drop' : 'Accept'} - - {a.status === 'pending' ? ( - - ) : null} - {a.status === 'approved' ? ( - - ) : null} - {a.install_curl ? ( - - ) : null} - - - } - > - - - - setOverrideOpen(true)}> - - IP override - - setCloneOpen(true)}> - - Копировать наборы - - {a.install_curl ? ( - { - copyToClipboard(a.install_curl!) - toast.success('Команда установки скопирована') - installRef.current?.scrollIntoView({ - behavior: 'smooth', - }) - }} - > - - Команда установки - - ) : null} - {onDelete ? ( - <> - - onDelete(agentId)} - > - - Удалить агента - - - ) : null} - - - - } - /> - - {a.last_apply_error ? ( - - - Ошибка применения политики - {a.last_apply_error} - - ) : null} - - , - iconClassName: 'text-warning', - label: 'Трафик', - description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`, - hint: 'накопительно', - variant: 'warning', - footer: ( - - ), - }, - { - id: 'kernel', - icon: , - iconClassName: 'text-info', - label: 'Ядро', - description: a.last_apply_kernel_method ?? '—', - }, - { - id: 'apply', - icon: , - iconClassName: 'text-primary', - label: 'Последнее применение', - description: formatDateTime(a.last_apply_at), - hint: a.last_apply_at - ? formatRelativeTime(a.last_apply_at) - : undefined, - }, - ]} - /> - - -
-
-
- -
- -
- -
- -
- - - - {a.platform === 'linux' ? ( -
- - - - - - - - - - - - -
- ) : ( - - )} -
-
-
-
- - - - - { - setRevokeOpen(false) - revoke.mutate() - }} - disabled={revoke.isPending} - /> - - { - setResetStatsOpen(false) - resetStats.mutate() - }} - disabled={resetStats.isPending} - /> - - ) -} diff --git a/apps/web/src/routes/_auth/agents/$id.tsx b/apps/web/src/routes/_auth/agents/$id.tsx index 56f2559..590db0a 100644 --- a/apps/web/src/routes/_auth/agents/$id.tsx +++ b/apps/web/src/routes/_auth/agents/$id.tsx @@ -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 +} diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 68e07d1..fff7780 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -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() { }} /> - { - if (!open) setSearch({ agent: '' }) - }} - onDelete={handleDeleteAgent} - /> - {