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 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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user