feat(web): refactor agent detail and agents page for improved navigation and layout
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated the AgentDetailSheet component to enhance layout and integrate a new detail view for agents, improving user experience.
- Refactored the agents page to support a toggle between card and table views, allowing for better organization and accessibility of agent information.
- Adjusted routing for agent links to utilize search parameters, streamlining navigation to specific agent details.
- Removed unused imports and optimized component structure for better maintainability.

These changes contribute to a more intuitive and user-friendly interface across the application.
This commit is contained in:
Denozordec
2026-07-23 12:14:46 +07:00
parent 3058704585
commit 19a9540555
41 changed files with 5901 additions and 601 deletions
+8 -278
View File
@@ -1,285 +1,15 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useRef, useState } from 'react'
import {
BanIcon,
CheckCircle2Icon,
CircleAlertIcon,
ClockIcon,
Copy,
CopyPlusIcon,
CpuIcon,
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
} from 'lucide-react'
import { DetailPanel, PageShell } from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { StatusBadge } from '@/components/status-badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
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 {
AgentCloneSetsSheet,
AgentOverrideSheet,
} from '@/components/agents/agent-settings-sheets'
import {
agentPreviewQueryOptions,
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import { createFileRoute, redirect } from '@tanstack/react-router'
/**
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
* Preview: https://reui.io/preview/base/solution-agents-3
* · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/blocks/solutions/agents
* Legacy deep-link → list + Sheet (`?agent=`).
* Full UI: AgentDetailSheet on /agents (SA3 / inventory-9).
*/
export const Route = createFileRoute('/_auth/agents/$id')({
loader: async ({ context: { queryClient }, params }) => {
const agent = await queryClient.ensureQueryData(
agentQueryOptions(params.id),
)
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
return { breadcrumb: agent.name }
beforeLoad: ({ params }) => {
throw redirect({
to: '/agents',
search: { agent: params.id, view: 'cards' },
})
},
component: AgentDetailPage,
})
function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(id))
const previewQ = useQuery(agentPreviewQueryOptions(id))
const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false)
const revoke = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/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/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (agentQ.isLoading || !a) {
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header title="Агент" description="Загрузка…" />
</DetailPanel>
<Skeleton className="h-40 w-full rounded-xl" />
</PageShell>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
]
.filter(Boolean)
.join(' · ')
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={headerDesc}
actions={
<>
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate()}
disabled={approve.isPending}
>
Approve
</Button>
) : null}
{a.status === 'approved' ? (
<Button
variant="outline"
size="sm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
Revoke
</Button>
) : null}
{a.install_curl ? (
<Button
variant="outline"
size="sm"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Install
</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" />
Install curl
</DropdownMenuItem>
) : null}
<DropdownMenuItem
render={<Link to="/agents" />}
>
К списку
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
<DetailPanel.Metrics
cards={[
{
id: 'dropped',
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0),
hint: 'сумма counters',
variant: 'warning',
},
{
id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0),
hint: 'сумма counters',
},
{
id: 'kernel',
icon: <CpuIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Kernel',
description: a.last_apply_kernel_method ?? '—',
},
{
id: 'apply',
icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Last apply',
description: a.last_apply_at ?? '—',
},
]}
/>
<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={id} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
<AgentOverrideSheet
agentId={id}
open={overrideOpen}
onOpenChange={setOverrideOpen}
/>
<AgentCloneSetsSheet
agentId={id}
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
</PageShell>
)
}
+162 -26
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
@@ -7,10 +7,12 @@ import {
CircleAlertIcon,
FilterIcon,
Inbox,
LayoutGridIcon,
ListIcon,
Plus,
SearchIcon,
ShieldIcon,
TableIcon,
UserPlus,
WifiOff,
} from 'lucide-react'
@@ -40,12 +42,14 @@ import { CountedLineTabs } from '@/components/counted-line-tabs'
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 {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
InputGroup,
@@ -53,14 +57,36 @@ import {
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evofw/ui/components/toggle-group'
import type { Agent } from '@evofw/shared'
/**
* Agents ops console — card catalog + detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
* Agents ops console — cards/table toggle + full detail Sheet.
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · solution-agents-2
* · https://reui.io/preview/base/solution-inventory-9 · solution-agents-3
*/
type AgentsSearch = {
agent?: string
view: 'cards' | 'table'
}
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
const view = search.view === 'table' ? 'table' : 'cards'
const agent =
typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
return { view, agent }
}
export const Route = createFileRoute('/_auth/agents/')({
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
parseAgentsSearch(search),
component: AgentsPage,
})
@@ -73,14 +99,46 @@ const AGENT_TABS = [
] as const
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
const [detailAgentId, setDetailAgentId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const detailOpen = Boolean(detailAgentId)
const setSearch = useCallback(
(next: Partial<AgentsSearch>) => {
void navigate({
search: (prev) => {
const base = parseAgentsSearch(prev as Record<string, unknown>)
return {
view: next.view ?? base.view,
agent:
next.agent !== undefined
? next.agent || undefined
: base.agent,
} satisfies AgentsSearch
},
replace: true,
})
},
[navigate],
)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
@@ -177,6 +235,14 @@ function AgentsPage() {
[searchQuery],
)
const getSearchText = useCallback(
(item: Agent) =>
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
.filter(Boolean)
.join(' '),
[],
)
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
@@ -257,16 +323,50 @@ function AgentsPage() {
[counts.pending],
)
const handleSelectAgent = useCallback((id: string) => {
setDetailAgentId(id)
setDetailOpen(true)
}, [])
const handleSelectAgent = useCallback(
(id: string) => {
setSearch({ agent: id })
},
[setSearch],
)
const handleClearFilters = useCallback(() => {
setFilters([])
setSearchQuery('')
}, [])
const handleCopyInstall = useCallback(
(curl: string) => {
copyToClipboard(curl)
toast.success('Скопировано')
},
[copyToClipboard],
)
const viewToggle = (
<ToggleGroup
multiple={false}
value={[view]}
onValueChange={(next) => {
const v = next[0]
if (v === 'cards' || v === 'table') {
setSearch({ view: v })
}
}}
variant="outline"
size="sm"
spacing={0}
aria-label="Вид списка"
>
<ToggleGroupItem value="cards" aria-label="Карточки">
<LayoutGridIcon className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Таблица">
<TableIcon className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
)
const addButton = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
@@ -329,12 +429,40 @@ function AgentsPage() {
</Alert>
) : null}
{agentsQ.isError ? (
{view === 'table' ? (
<AgentFleetDataGrid
data={items}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={handleClearFilters}
getFilterFieldValue={getFilterFieldValue}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
getSearchText={getSearchText}
tabs={[...AGENT_TABS]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={agentsQ.isLoading}
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
toolbarExtra={viewToggle}
onSelect={handleSelectAgent}
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
onCopyInstall={handleCopyInstall}
/>
) : agentsQ.isError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
<span>
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
</span>
<Button
type="button"
variant="outline"
@@ -364,7 +492,11 @@ function AgentsPage() {
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<Button
type="button"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
@@ -382,16 +514,19 @@ function AgentsPage() {
/>
</InputGroup>
</div>
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
<div className="flex flex-wrap items-center gap-2">
{viewToggle}
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
</div>
</div>
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
<AgentCardsGrid
@@ -400,7 +535,9 @@ function AgentsPage() {
onSelect={handleSelectAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
@@ -417,11 +554,10 @@ function AgentsPage() {
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
<AgentDetailSheet
agentId={detailAgentId}
agentId={detailAgentId ?? null}
open={detailOpen}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) setDetailAgentId(null)
if (!open) setSearch({ agent: '' })
}}
/>
</PageShell>
+4 -4
View File
@@ -161,8 +161,8 @@ function DashboardPage() {
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/agents/$id"
params={{ id: a.id }}
to="/agents"
search={{ agent: a.id, view: 'cards' }}
className="hover:underline"
>
{a.name}
@@ -236,8 +236,8 @@ function DashboardPage() {
Pending: <strong>{a.name}</strong>
</span>
<Link
to="/agents/$id"
params={{ id: a.id }}
to="/agents"
search={{ agent: a.id, view: 'cards' }}
className="underline-offset-4 hover:underline"
>
Открыть