feat(web): add delete functionality to agent components
- 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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user