diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx index dcd8162..7402976 100644 --- a/apps/web/src/components/agents/agent-detail-view.tsx +++ b/apps/web/src/components/agents/agent-detail-view.tsx @@ -38,6 +38,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports' import { AgentHostFirewall } from '@/components/agents/agent-host-firewall' import { AgentPortAcl } from '@/components/agents/agent-port-acl' import { CountedLineTabs } from '@/components/counted-line-tabs' +import { ConfirmDialog } from '@/components/confirm-dialog' import { AgentCloneSetsSheet, AgentOverrideSheet, @@ -79,6 +80,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { const installRef = useRef(null) const [overrideOpen, setOverrideOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false) + const [revokeOpen, setRevokeOpen] = useState(false) + const [resetStatsOpen, setResetStatsOpen] = useState(false) const [fwTab, setFwTab] = useState('host') const revoke = useMutation({ @@ -187,10 +190,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { ) : null} {a.install_curl ? ( @@ -283,7 +286,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { size="sm" variant="outline" disabled={resetStats.isPending} - onClick={() => resetStats.mutate()} + onClick={() => setResetStatsOpen(true)} > Сбросить @@ -374,6 +377,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) { open={cloneOpen} onOpenChange={setCloneOpen} /> + + { + setRevokeOpen(false) + revoke.mutate() + }} + disabled={revoke.isPending} + /> + + { + setResetStatsOpen(false) + resetStats.mutate() + }} + disabled={resetStats.isPending} + /> ) } 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 53f5b62..f011dc9 100644 --- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx +++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react' -import type { ColumnDef } from '@tanstack/react-table' +import type { ColumnDef, RowSelectionState } from '@tanstack/react-table' import { Check, Copy, PanelRight, Trash2 } from 'lucide-react' import type { Agent } from '@evofw/shared' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' @@ -55,6 +55,15 @@ export type AgentFleetDataGridProps = { onRetry?: () => void emptyAction?: ReactNode toolbarExtra?: ReactNode + /** Bulk-операции: controlled selection пробрасывается в ResourcePage. */ + enableRowSelection?: boolean + rowSelection?: RowSelectionState + onRowSelectionChange?: (rowSelection: RowSelectionState) => void + selectionToolbar?: (ctx: { + selectedIds: string[] + selectedCount: number + clearSelection: () => void + }) => ReactNode onSelect: (id: string) => void onApprove: (id: string) => void approvePending?: boolean @@ -82,6 +91,10 @@ export function AgentFleetDataGrid({ onRetry, emptyAction, toolbarExtra, + enableRowSelection, + rowSelection, + onRowSelectionChange, + selectionToolbar, onSelect, onApprove, approvePending, @@ -346,6 +359,10 @@ export function AgentFleetDataGrid({ getSearchText={getSearchText} tableLayout={{ width: 'fixed', columnsResizable: true }} onRowClick={(row) => onSelect(row.id)} + enableRowSelection={enableRowSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + selectionToolbar={selectionToolbar} tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx index 3f25a8f..2ba4305 100644 --- a/apps/web/src/components/confirm-dialog.tsx +++ b/apps/web/src/components/confirm-dialog.tsx @@ -18,6 +18,8 @@ interface ConfirmDialogProps { title: string description: string confirmLabel?: string + /** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */ + confirmVariant?: 'default' | 'destructive' cancelLabel?: string onConfirm: () => void disabled?: boolean @@ -30,6 +32,7 @@ export function ConfirmDialog({ title, description, confirmLabel = 'Удалить', + confirmVariant = 'destructive', cancelLabel = 'Отмена', onConfirm, disabled, @@ -46,7 +49,7 @@ export function ConfirmDialog({ {cancelLabel} - + {confirmLabel} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 3c39d09..578ce9c 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -80,6 +80,9 @@ export interface ResourcePageProps { emptyState?: { title: string; description?: string; action?: ReactNode } pageSize?: number enableRowSelection?: boolean + /** Controlled selection — page owns state, so bulk-действия видят выбор. */ + rowSelection?: RowSelectionState + onRowSelectionChange?: (rowSelection: RowSelectionState) => void selectionToolbar?: (ctx: { selectedIds: string[] selectedCount: number @@ -141,6 +144,8 @@ export function ResourcePage({ emptyState, pageSize = 10, enableRowSelection = false, + rowSelection: rowSelectionProp, + onRowSelectionChange: onRowSelectionChangeProp, selectionToolbar, toolbarExtra, hideHeader = false, @@ -156,7 +161,30 @@ export function ResourcePage({ const activeTab = controlledTab ?? internalTab const [sorting, setSorting] = useState([]) - const [rowSelection, setRowSelection] = useState({}) + const isSelectionControlled = rowSelectionProp !== undefined + const [internalRowSelection, setInternalRowSelection] = + useState({}) + const rowSelection = isSelectionControlled + ? rowSelectionProp + : internalRowSelection + const handleRowSelectionChange = useCallback( + ( + updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState), + ) => { + const current = isSelectionControlled + ? rowSelectionProp + : internalRowSelection + const next = typeof updater === 'function' ? updater(current) : updater + if (isSelectionControlled) onRowSelectionChangeProp?.(next) + else setInternalRowSelection(next) + }, + [ + isSelectionControlled, + rowSelectionProp, + internalRowSelection, + onRowSelectionChangeProp, + ], + ) const [columnPinning, setColumnPinning] = useState( () => columnPinningProp ?? {}, ) @@ -224,8 +252,8 @@ export function ResourcePage({ const selectedCount = selectedIds.length const clearSelection = useCallback(() => { - setRowSelection({}) - }, []) + handleRowSelectionChange({}) + }, [handleRowSelectionChange]) const tableLayout = useMemo( () => ({ @@ -253,7 +281,7 @@ export function ResourcePage({ // без drag-handles — enableColumnResizing остаётся false. enableColumnResizing: false, onSortingChange: setSorting, - onRowSelectionChange: setRowSelection, + onRowSelectionChange: handleRowSelectionChange, onPaginationChange: setPagination, onColumnPinningChange: setColumnPinning, getCoreRowModel: getCoreRowModel(), diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 1eedf0b..ea4a8f1 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { RowSelectionState } from '@tanstack/react-table' import { toast } from 'sonner' import { useCallback, useMemo, useState } from 'react' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' @@ -13,6 +14,7 @@ import { Plus, ShieldIcon, TableIcon, + Trash2, UserPlus, WifiOff, } from 'lucide-react' @@ -29,6 +31,7 @@ import { FrameHeader, FrameTitle, } from '@/components/reui/frame' +import { Badge } from '@/components/reui/badge' import { Alert, AlertDescription, @@ -85,12 +88,18 @@ export const Route = createFileRoute('/_auth/agents/')({ const AGENT_TABS = [ { id: 'all', label: 'Все' }, - { id: 'invited', label: 'Invited' }, - { id: 'pending', label: 'Pending' }, - { id: 'approved', label: 'Approved' }, - { id: 'revoked', label: 'Revoked' }, + { id: 'invited', label: 'Приглашённые' }, + { id: 'pending', label: 'Ожидают' }, + { id: 'approved', label: 'Одобренные' }, + { id: 'revoked', label: 'Отозванные' }, ] as const +/** Список имён для подтверждений: первые 5, дальше «и ещё N». */ +function formatNames(names: string[]): string { + if (names.length <= 5) return names.join(', ') + return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}` +} + function AgentsPage() { const navigate = useNavigate({ from: Route.fullPath }) const { agent: detailAgentId, view } = Route.useSearch() @@ -101,6 +110,9 @@ function AgentsPage() { const canWrite = useCan()('fw:agents:write') const [createOpen, setCreateOpen] = useState(false) const [deleteAgentId, setDeleteAgentId] = useState(null) + const [approveAllOpen, setApproveAllOpen] = useState(false) + const [bulkDeleteIds, setBulkDeleteIds] = useState(null) + const [rowSelection, setRowSelection] = useState({}) const [filters, setFilters] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [activeTab, setActiveTab] = useState('all') @@ -156,7 +168,7 @@ function AgentsPage() { }, }) - const approveAllPending = useMutation({ + const approveBulk = useMutation({ mutationFn: (ids: string[]) => apiFetch('/api/v1/agents/approve-bulk', { method: 'POST', @@ -170,9 +182,39 @@ function AgentsPage() { ), ) }, - onSuccess: () => { - toast.success('Все pending одобрены') + onSuccess: (_data, ids) => { + toast.success(`Одобрено агентов: ${ids.length}`) + setRowSelection({}) void qc.invalidateQueries({ queryKey: ['agents'] }) + void qc.invalidateQueries({ queryKey: ['dashboard'] }) + }, + onError: (e: Error, _ids, rollback) => { + rollback?.() + toast.error(e.message) + }, + }) + + const deleteBulk = useMutation({ + mutationFn: (ids: string[]) => + apiFetch('/api/v1/agents/delete-bulk', { + method: 'POST', + body: JSON.stringify({ agent_ids: ids }), + }), + onMutate: (ids) => { + const target = new Set(ids) + return patchAgentsCache((items) => + items.filter((a) => !target.has(a.id)), + ) + }, + onSuccess: (_data, ids) => { + toast.success(`Удалено агентов: ${ids.length}`) + setBulkDeleteIds(null) + setRowSelection({}) + if (detailAgentId && ids.includes(detailAgentId)) { + setSearch({ agent: '' }) + } + void qc.invalidateQueries({ queryKey: ['agents'] }) + void qc.invalidateQueries({ queryKey: ['dashboard'] }) }, onError: (e: Error, _ids, rollback) => { rollback?.() @@ -205,6 +247,77 @@ function AgentsPage() { () => items.filter((a) => a.status === 'pending').map((a) => a.id), [items], ) + const pendingNames = useMemo( + () => + items.filter((a) => a.status === 'pending').map((a) => a.name), + [items], + ) + + const selectedIds = useMemo( + () => Object.keys(rowSelection).filter((id) => rowSelection[id]), + [rowSelection], + ) + const selectedAgents = useMemo( + () => items.filter((a) => selectedIds.includes(a.id)), + [items, selectedIds], + ) + const approvableSelected = useMemo( + () => + selectedAgents.filter( + (a) => a.status === 'pending' || a.status === 'invited', + ), + [selectedAgents], + ) + const clearSelection = useCallback(() => setRowSelection({}), []) + + const selectionToolbar = useCallback( + (ctx: { selectedCount: number }) => { + if (ctx.selectedCount === 0 || !canWrite) return null + return ( +
+ + {ctx.selectedCount} выбрано + +
+ {approvableSelected.length > 0 ? ( + + ) : null} + + +
+
+ ) + }, + [ + canWrite, + approvableSelected, + selectedIds, + approveBulk.isPending, + deleteBulk.isPending, + clearSelection, + ], + ) const kpiCards = useMemo( () => @@ -460,12 +573,12 @@ function AgentsPage() { ) : null} @@ -505,6 +618,10 @@ function AgentsPage() { onRetry={() => void agentsQ.refetch()} emptyAction={addButton} toolbarExtra={viewToggle} + enableRowSelection={canWrite} + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + selectionToolbar={selectionToolbar} onSelect={handleSelectAgent} onApprove={(id) => approve.mutate(id)} approvePending={approve.isPending} @@ -578,6 +695,45 @@ function AgentsPage() { }} disabled={removeAgent.isPending} /> + + 0 + ? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.` + : 'Нет агентов, ожидающих одобрения.' + } + onConfirm={() => { + setApproveAllOpen(false) + approveBulk.mutate(pendingIds) + }} + disabled={approveBulk.isPending || pendingIds.length === 0} + /> + + { + if (!open) setBulkDeleteIds(null) + }} + title="Удалить выбранных агентов?" + description={ + bulkDeleteIds && bulkDeleteIds.length > 0 + ? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames( + selectedAgents + .filter((a) => bulkDeleteIds.includes(a.id)) + .map((a) => a.name), + )}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.` + : 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.' + } + onConfirm={() => { + if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds) + }} + disabled={deleteBulk.isPending} + /> ) }