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
@@ -0,0 +1,302 @@
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 } 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 { 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'
/**
* 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
}
export function AgentDetailView({ agentId }: 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 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 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>
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
</AlertDescription>
</Alert>
</div>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
]
.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} />
<StatusBadge status={a.status} />
<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}
>
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}
</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={agentId} />
</div>
<AgentEffectiveCidrs
preview={previewQ.data}
isLoading={previewQ.isLoading}
/>
</div>
</DetailPanel.Section>
</DetailPanel>
</div>
<AgentOverrideSheet
agentId={agentId}
open={overrideOpen}
onOpenChange={setOverrideOpen}
/>
<AgentCloneSetsSheet
agentId={agentId}
open={cloneOpen}
onOpenChange={setCloneOpen}
/>
</>
)
}