diff --git a/apps/web/src/components/agents/agent-card.tsx b/apps/web/src/components/agents/agent-card.tsx index c4f60d7..921bbd6 100644 --- a/apps/web/src/components/agents/agent-card.tsx +++ b/apps/web/src/components/agents/agent-card.tsx @@ -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 ( - + {agent.last_apply_error ? ( +

+ {agent.last_apply_error} +

+ ) : agent.last_apply_kernel_method ? ( +

+ kernel · {agent.last_apply_kernel_method} +

+ ) : null} + + + + + ) } diff --git a/apps/web/src/components/agents/agent-cards-grid.tsx b/apps/web/src/components/agents/agent-cards-grid.tsx index d9ec073..ebb2f37 100644 --- a/apps/web/src/components/agents/agent-cards-grid.tsx +++ b/apps/web/src/components/agents/agent-cards-grid.tsx @@ -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} /> ))} diff --git a/apps/web/src/components/agents/agent-detail-sheet.tsx b/apps/web/src/components/agents/agent-detail-sheet.tsx index 67fad83..f3cefad 100644 --- a/apps/web/src/components/agents/agent-detail-sheet.tsx +++ b/apps/web/src/components/agents/agent-detail-sheet.tsx @@ -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({
{agentId && open ? ( - + ) : null}
diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx index 1d82534..1fd06fd 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -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 ) : null} + {onDelete ? ( + <> + + onDelete(agentId)} + > + + Удалить агента + + + ) : null} diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx index 6e5bfc9..74031b5 100644 --- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx +++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx @@ -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: () => Действия, @@ -329,12 +331,24 @@ export function AgentFleetDataGrid({ > + ) }, }, ], - [approvePending, handleCopy, onApprove, onSelect], + [approvePending, handleCopy, onApprove, onDelete, onSelect], ) return ( diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 46a1c1c..c88185d 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -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(null) const [filters, setFilters] = useState([]) 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 ? ( @@ -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} + /> + + { + if (!open) setDeleteAgentId(null) + }} + title="Удалить агента?" + description={ + deleteTargetName + ? `Агент «${deleteTargetName}» будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.` + : 'Агент будет удалён вместе с overrides, install-ссылками и статистикой. Это действие нельзя отменить.' + } + onConfirm={() => { + if (deleteAgentId) removeAgent.mutate(deleteAgentId) + }} + disabled={removeAgent.isPending} /> )