feat(web): add delete functionality to agent components
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m59s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Introduced delete functionality across multiple agent components, including AgentCard, AgentCardsGrid, AgentDetailSheet, and AgentDetailView, allowing users to remove agents directly from the UI.
- Integrated a confirmation dialog to prevent accidental deletions, enhancing user experience and safety.
- Updated relevant props and handlers to manage delete actions consistently across components.

These changes improve the overall management of agents, providing users with the ability to easily delete agents while ensuring confirmation for critical actions.
This commit is contained in:
Denozordec
2026-07-25 16:59:05 +07:00
parent 837b29735f
commit fb95ef22b3
6 changed files with 186 additions and 86 deletions
+99 -79
View File
@@ -1,4 +1,5 @@
import type { Agent } from '@evofw/shared'
import { Trash2 } from 'lucide-react'
import {
AgentPlatformIcon,
platformLabel,
@@ -11,6 +12,7 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button'
import {
Item,
ItemContent,
@@ -54,9 +56,15 @@ type AgentCardProps = {
agent: Agent
selected?: boolean
onSelect: (id: string) => void
onDelete: (id: string) => void
}
export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
export function AgentCard({
agent,
selected,
onSelect,
onDelete,
}: AgentCardProps) {
const hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
@@ -103,90 +111,102 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
] as const
return (
<button
type="button"
onClick={() => onSelect(agent.id)}
className={cn(
'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',
)}
>
<Frame
spacing="xs"
<div className="relative">
<button
type="button"
onClick={() => onSelect(agent.id)}
className={cn(
'bg-muted/50 dark:bg-muted/10 w-full transition-shadow',
selected
? 'ring-primary/30 ring-2'
: 'hover:ring-border hover:ring-1',
'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',
)}
>
<FramePanel className="text-card-foreground isolate flex flex-col gap-4 px-4 py-4">
<div className="flex items-start gap-3">
<AgentPlatformIcon platform={agent.platform} />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm leading-tight font-semibold">
{agent.name}
</h3>
<StatusBadge status={agent.status} />
<Badge
variant={
agent.default_action === 'drop'
? 'warning-light'
: 'success-light'
}
size="xs"
radius="full"
>
{defaultAction}
</Badge>
<Frame
spacing="xs"
className={cn(
'bg-muted/50 dark:bg-muted/10 w-full transition-shadow',
selected
? 'ring-primary/30 ring-2'
: 'hover:ring-border hover:ring-1',
)}
>
<FramePanel className="text-card-foreground isolate flex flex-col gap-4 px-4 py-4 pr-12">
<div className="flex items-start gap-3">
<AgentPlatformIcon platform={agent.platform} />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm leading-tight font-semibold">
{agent.name}
</h3>
<StatusBadge status={agent.status} />
<Badge
variant={
agent.default_action === 'drop'
? 'warning-light'
: 'success-light'
}
size="xs"
radius="full"
>
{defaultAction}
</Badge>
</div>
<p className="text-muted-foreground truncate text-xs">
{subtitle}
</p>
</div>
<p className="text-muted-foreground truncate text-xs">
{subtitle}
</p>
</div>
</div>
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr] overflow-hidden rounded-lg border">
{stats.map((stat, index) => (
<div key={stat.label} className="contents">
<Item
variant="muted"
size="sm"
className="justify-center bg-transparent px-2 py-3"
>
<ItemContent className="items-center gap-1">
<ItemTitle
className={cn(
'text-sm leading-none font-medium tabular-nums',
stat.valueClass,
)}
>
{stat.valueNode}
</ItemTitle>
<ItemDescription className="line-clamp-1 text-xs leading-tight">
{stat.label}
</ItemDescription>
</ItemContent>
</Item>
{index < stats.length - 1 ? (
<Separator orientation="vertical" className="my-2.5" />
) : null}
</div>
))}
</div>
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr] overflow-hidden rounded-lg border">
{stats.map((stat, index) => (
<div key={stat.label} className="contents">
<Item
variant="muted"
size="sm"
className="justify-center bg-transparent px-2 py-3"
>
<ItemContent className="items-center gap-1">
<ItemTitle
className={cn(
'text-sm leading-none font-medium tabular-nums',
stat.valueClass,
)}
>
{stat.valueNode}
</ItemTitle>
<ItemDescription className="line-clamp-1 text-xs leading-tight">
{stat.label}
</ItemDescription>
</ItemContent>
</Item>
{index < stats.length - 1 ? (
<Separator orientation="vertical" className="my-2.5" />
) : null}
</div>
))}
</div>
{agent.last_apply_error ? (
<p className="text-destructive truncate text-xs">
{agent.last_apply_error}
</p>
) : agent.last_apply_kernel_method ? (
<p className="text-muted-foreground truncate text-xs">
kernel · {agent.last_apply_kernel_method}
</p>
) : null}
</FramePanel>
</Frame>
</button>
{agent.last_apply_error ? (
<p className="text-destructive truncate text-xs">
{agent.last_apply_error}
</p>
) : agent.last_apply_kernel_method ? (
<p className="text-muted-foreground truncate text-xs">
kernel · {agent.last_apply_kernel_method}
</p>
) : null}
</FramePanel>
</Frame>
</button>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="text-destructive absolute top-3 right-3 z-10"
aria-label="Удалить"
onClick={() => onDelete(agent.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
}
@@ -13,6 +13,7 @@ type AgentCardsGridProps = {
agents: Agent[]
selectedId?: string | null
onSelect: (id: string) => void
onDelete: (id: string) => void
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
@@ -23,6 +24,7 @@ export function AgentCardsGrid({
agents,
selectedId,
onSelect,
onDelete,
isLoading,
emptyTitle = 'Нет агентов',
emptyDescription,
@@ -58,6 +60,7 @@ export function AgentCardsGrid({
agent={agent}
selected={selectedId === agent.id}
onSelect={onSelect}
onDelete={onDelete}
/>
))}
</div>
@@ -27,12 +27,14 @@ 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 ?? ''),
@@ -81,7 +83,7 @@ export function AgentDetailSheet({
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{agentId && open ? (
<AgentDetailView agentId={agentId} />
<AgentDetailView agentId={agentId} onDelete={onDelete} />
) : null}
</ScrollArea>
</div>
@@ -11,6 +11,7 @@ import {
MoreHorizontalIcon,
ShieldPlusIcon,
TerminalIcon,
Trash2,
} from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import {
@@ -48,6 +49,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
@@ -60,9 +62,10 @@ import {
type AgentDetailViewProps = {
agentId: string
onDelete?: (id: string) => void
}
export function AgentDetailView({ agentId }: AgentDetailViewProps) {
export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(agentId))
@@ -229,6 +232,18 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
Install curl
</DropdownMenuItem>
) : null}
{onDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(agentId)}
>
<Trash2 className="size-4" />
Удалить агента
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Check, Copy, PanelRight } from 'lucide-react'
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
import type { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
@@ -82,6 +82,7 @@ export type AgentFleetDataGridProps = {
onApprove: (id: string) => void
approvePending?: boolean
onCopyInstall: (curl: string) => void
onDelete: (id: string) => void
}
export function AgentFleetDataGrid({
@@ -108,6 +109,7 @@ export function AgentFleetDataGrid({
onApprove,
approvePending,
onCopyInstall,
onDelete,
}: AgentFleetDataGridProps) {
const handleCopy = useCallback(
(curl: string, e?: MouseEvent) => {
@@ -284,9 +286,9 @@ export function AgentFleetDataGrid({
},
{
id: 'actions',
size: 120,
minSize: 120,
maxSize: 120,
size: 152,
minSize: 152,
maxSize: 160,
enableSorting: false,
enableResizing: false,
header: () => <span className="sr-only">Действия</span>,
@@ -329,12 +331,24 @@ export function AgentFleetDataGrid({
>
<PanelRight className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={(e) => {
e.stopPropagation()
onDelete(a.id)
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)
},
},
],
[approvePending, handleCopy, onApprove, onSelect],
[approvePending, handleCopy, onApprove, onDelete, onSelect],
)
return (
@@ -47,6 +47,7 @@ import {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -105,6 +106,7 @@ function AgentsPage() {
const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
@@ -155,6 +157,21 @@ function AgentsPage() {
onError: (e: Error) => toast.error(e.message),
})
const removeAgent = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onSuccess: (_data, id) => {
toast.success('Агент удалён')
setDeleteAgentId(null)
if (detailAgentId === id) {
setSearch({ agent: '' })
}
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
})
const items = agentsQ.data?.items ?? []
const counts = useMemo(() => computeFleetCounts(items), [items])
const pendingIds = useMemo(
@@ -330,6 +347,15 @@ function AgentsPage() {
[setSearch],
)
const handleDeleteAgent = useCallback((id: string) => {
setDeleteAgentId(id)
}, [])
const deleteTargetName = useMemo(
() => items.find((a) => a.id === deleteAgentId)?.name,
[items, deleteAgentId],
)
const handleClearFilters = useCallback(() => {
setFilters([])
setSearchQuery('')
@@ -454,6 +480,7 @@ function AgentsPage() {
onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending}
onCopyInstall={handleCopyInstall}
onDelete={handleDeleteAgent}
/>
) : agentsQ.isError ? (
<Alert variant="destructive">
@@ -533,6 +560,7 @@ function AgentsPage() {
agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0
@@ -559,6 +587,24 @@ function AgentsPage() {
onOpenChange={(open) => {
if (!open) setSearch({ agent: '' })
}}
onDelete={handleDeleteAgent}
/>
<ConfirmDialog
open={deleteAgentId !== null}
onOpenChange={(open) => {
if (!open) setDeleteAgentId(null)
}}
title="Удалить агента?"
description={
deleteTargetName
? `Агент «${deleteTargetName}» будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.`
: 'Агент будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.'
}
onConfirm={() => {
if (deleteAgentId) removeAgent.mutate(deleteAgentId)
}}
disabled={removeAgent.isPending}
/>
</PageShell>
)