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
+22 -2
View File
@@ -1,4 +1,5 @@
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
import { Trash2 } from 'lucide-react'
import { import {
AgentPlatformIcon, AgentPlatformIcon,
platformLabel, platformLabel,
@@ -11,6 +12,7 @@ import {
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button'
import { import {
Item, Item,
ItemContent, ItemContent,
@@ -54,9 +56,15 @@ type AgentCardProps = {
agent: Agent agent: Agent
selected?: boolean selected?: boolean
onSelect: (id: string) => void 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 hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agentTrafficDropped(agent), hasApply) const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply) const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
@@ -103,6 +111,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
] as const ] as const
return ( return (
<div className="relative">
<button <button
type="button" type="button"
onClick={() => onSelect(agent.id)} onClick={() => onSelect(agent.id)}
@@ -120,7 +129,7 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
: 'hover:ring-border hover:ring-1', : 'hover:ring-border hover:ring-1',
)} )}
> >
<FramePanel className="text-card-foreground isolate flex flex-col gap-4 px-4 py-4"> <FramePanel className="text-card-foreground isolate flex flex-col gap-4 px-4 py-4 pr-12">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AgentPlatformIcon platform={agent.platform} /> <AgentPlatformIcon platform={agent.platform} />
<div className="flex min-w-0 flex-1 flex-col gap-1.5"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
@@ -188,5 +197,16 @@ export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
</FramePanel> </FramePanel>
</Frame> </Frame>
</button> </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[] agents: Agent[]
selectedId?: string | null selectedId?: string | null
onSelect: (id: string) => void onSelect: (id: string) => void
onDelete: (id: string) => void
isLoading?: boolean isLoading?: boolean
emptyTitle?: string emptyTitle?: string
emptyDescription?: string emptyDescription?: string
@@ -23,6 +24,7 @@ export function AgentCardsGrid({
agents, agents,
selectedId, selectedId,
onSelect, onSelect,
onDelete,
isLoading, isLoading,
emptyTitle = 'Нет агентов', emptyTitle = 'Нет агентов',
emptyDescription, emptyDescription,
@@ -58,6 +60,7 @@ export function AgentCardsGrid({
agent={agent} agent={agent}
selected={selectedId === agent.id} selected={selectedId === agent.id}
onSelect={onSelect} onSelect={onSelect}
onDelete={onDelete}
/> />
))} ))}
</div> </div>
@@ -27,12 +27,14 @@ type AgentDetailSheetProps = {
agentId: string | null agentId: string | null
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onDelete?: (id: string) => void
} }
export function AgentDetailSheet({ export function AgentDetailSheet({
agentId, agentId,
open, open,
onOpenChange, onOpenChange,
onDelete,
}: AgentDetailSheetProps) { }: AgentDetailSheetProps) {
const agentQ = useQuery({ const agentQ = useQuery({
...agentQueryOptions(agentId ?? ''), ...agentQueryOptions(agentId ?? ''),
@@ -81,7 +83,7 @@ export function AgentDetailSheet({
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
{agentId && open ? ( {agentId && open ? (
<AgentDetailView agentId={agentId} /> <AgentDetailView agentId={agentId} onDelete={onDelete} />
) : null} ) : null}
</ScrollArea> </ScrollArea>
</div> </div>
@@ -11,6 +11,7 @@ import {
MoreHorizontalIcon, MoreHorizontalIcon,
ShieldPlusIcon, ShieldPlusIcon,
TerminalIcon, TerminalIcon,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit' import { DetailPanel } from '@/components/reui-kit'
import { import {
@@ -48,6 +49,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu' } from '@evofw/ui/components/dropdown-menu'
@@ -60,9 +62,10 @@ import {
type AgentDetailViewProps = { type AgentDetailViewProps = {
agentId: string agentId: string
onDelete?: (id: string) => void
} }
export function AgentDetailView({ agentId }: AgentDetailViewProps) { export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const qc = useQueryClient() const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(agentId)) const agentQ = useQuery(agentQueryOptions(agentId))
@@ -229,6 +232,18 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
Install curl Install curl
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{onDelete ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(agentId)}
>
<Trash2 className="size-4" />
Удалить агента
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react' import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table' 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 { Agent } from '@evofw/shared'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
@@ -82,6 +82,7 @@ export type AgentFleetDataGridProps = {
onApprove: (id: string) => void onApprove: (id: string) => void
approvePending?: boolean approvePending?: boolean
onCopyInstall: (curl: string) => void onCopyInstall: (curl: string) => void
onDelete: (id: string) => void
} }
export function AgentFleetDataGrid({ export function AgentFleetDataGrid({
@@ -108,6 +109,7 @@ export function AgentFleetDataGrid({
onApprove, onApprove,
approvePending, approvePending,
onCopyInstall, onCopyInstall,
onDelete,
}: AgentFleetDataGridProps) { }: AgentFleetDataGridProps) {
const handleCopy = useCallback( const handleCopy = useCallback(
(curl: string, e?: MouseEvent) => { (curl: string, e?: MouseEvent) => {
@@ -284,9 +286,9 @@ export function AgentFleetDataGrid({
}, },
{ {
id: 'actions', id: 'actions',
size: 120, size: 152,
minSize: 120, minSize: 152,
maxSize: 120, maxSize: 160,
enableSorting: false, enableSorting: false,
enableResizing: false, enableResizing: false,
header: () => <span className="sr-only">Действия</span>, header: () => <span className="sr-only">Действия</span>,
@@ -329,12 +331,24 @@ export function AgentFleetDataGrid({
> >
<PanelRight className="size-3.5" /> <PanelRight className="size-3.5" />
</Button> </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> </div>
) )
}, },
}, },
], ],
[approvePending, handleCopy, onApprove, onSelect], [approvePending, handleCopy, onApprove, onDelete, onSelect],
) )
return ( return (
@@ -47,6 +47,7 @@ import {
computeFleetCounts, computeFleetCounts,
fleetKpiCards, fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis' } from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions } from '@/queries' import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -105,6 +106,7 @@ function AgentsPage() {
const agentsQ = useQuery(agentsQueryOptions()) const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>([]) const [filters, setFilters] = useState<Filter[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all') const [activeTab, setActiveTab] = useState('all')
@@ -155,6 +157,21 @@ function AgentsPage() {
onError: (e: Error) => toast.error(e.message), 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 items = agentsQ.data?.items ?? []
const counts = useMemo(() => computeFleetCounts(items), [items]) const counts = useMemo(() => computeFleetCounts(items), [items])
const pendingIds = useMemo( const pendingIds = useMemo(
@@ -330,6 +347,15 @@ function AgentsPage() {
[setSearch], [setSearch],
) )
const handleDeleteAgent = useCallback((id: string) => {
setDeleteAgentId(id)
}, [])
const deleteTargetName = useMemo(
() => items.find((a) => a.id === deleteAgentId)?.name,
[items, deleteAgentId],
)
const handleClearFilters = useCallback(() => { const handleClearFilters = useCallback(() => {
setFilters([]) setFilters([])
setSearchQuery('') setSearchQuery('')
@@ -454,6 +480,7 @@ function AgentsPage() {
onApprove={(id) => approve.mutate(id)} onApprove={(id) => approve.mutate(id)}
approvePending={approve.isPending} approvePending={approve.isPending}
onCopyInstall={handleCopyInstall} onCopyInstall={handleCopyInstall}
onDelete={handleDeleteAgent}
/> />
) : agentsQ.isError ? ( ) : agentsQ.isError ? (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -533,6 +560,7 @@ function AgentsPage() {
agents={filteredItems} agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null} selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent} onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading} isLoading={agentsQ.isLoading}
emptyTitle={ emptyTitle={
items.length === 0 items.length === 0
@@ -559,6 +587,24 @@ function AgentsPage() {
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setSearch({ agent: '' }) 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> </PageShell>
) )